1static IMG_LOGO_TEXT: &[u8] = include_bytes!("../assets/logo/logo-text.png");
5static IMG_LOGO_SMALL: &[u8] = include_bytes!("../assets/logo/small-logo.png");
6static IMG_ICON_C: &[u8] = include_bytes!("../assets/icons/c.png");
7static IMG_ICON_CPP: &[u8] = include_bytes!("../assets/icons/cpp.png");
8static IMG_ICON_CSHARP: &[u8] = include_bytes!("../assets/icons/c-sharp.png");
9static IMG_ICON_PYTHON: &[u8] = include_bytes!("../assets/icons/python.png");
10static IMG_ICON_SHELL: &[u8] = include_bytes!("../assets/icons/shell.png");
11static IMG_ICON_POWERSHELL: &[u8] = include_bytes!("../assets/icons/powershell.png");
12static IMG_ICON_JAVASCRIPT: &[u8] = include_bytes!("../assets/icons/java-script.png");
13static IMG_ICON_HTML: &[u8] = include_bytes!("../assets/icons/html-5.png");
14static IMG_ICON_JAVA: &[u8] = include_bytes!("../assets/icons/java.png");
15static IMG_ICON_VB: &[u8] = include_bytes!("../assets/icons/visual-basic.png");
16static IMG_ICON_ASSEMBLY: &[u8] = include_bytes!("../assets/icons/asm.png");
17static IMG_ICON_GO: &[u8] = include_bytes!("../assets/icons/go.png");
18static IMG_ICON_R: &[u8] = include_bytes!("../assets/icons/r.png");
19static IMG_ICON_XML: &[u8] = include_bytes!("../assets/icons/xml.png");
20static IMG_ICON_GROOVY: &[u8] = include_bytes!("../assets/icons/groovy.png");
21static IMG_ICON_DOCKERFILE: &[u8] = include_bytes!("../assets/icons/docker.png");
22static IMG_ICON_MAKEFILE: &[u8] = include_bytes!("../assets/icons/makefile.svg");
23static IMG_ICON_PERL: &[u8] = include_bytes!("../assets/icons/perl.svg");
24
25pub(crate) mod audit;
26pub use audit::{verify_audit_file, AuditVerifyReport};
27pub(crate) mod auth;
28pub(crate) mod confluence;
29pub(crate) mod error;
30pub(crate) mod git_browser;
31pub(crate) mod git_webhook;
32pub(crate) mod integrations;
33
34use std::{
35 collections::{HashMap, VecDeque},
36 fmt::Write,
37 fs,
38 net::{IpAddr, SocketAddr},
39 path::{Path, PathBuf},
40 process::Stdio,
41 sync::{Arc, OnceLock},
42 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
43};
44
45use anyhow::{Context, Result};
46use askama::Template;
47use axum::{
48 body::Body,
49 extract::{DefaultBodyLimit, Form, Path as AxumPath, Query, State},
50 http::{header, HeaderValue, Request, StatusCode},
51 middleware::{self, Next},
52 response::{Html, IntoResponse, Response},
53 routing::{get, post},
54 Json, Router,
55};
56use serde::{Deserialize, Serialize};
57use tokio::sync::Mutex;
58use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
59
60use sloc_config::{
61 AppConfig, BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy,
62 MixedLinePolicy,
63};
64use sloc_git::ScheduleStore;
65
66#[derive(Clone)]
67pub(crate) struct CspNonce(pub(crate) String);
68
69static CHART_JS: &[u8] = include_bytes!("../static/chart.umd.min.js");
70static REPORT_CHART_JS: &[u8] = include_bytes!("../static/chart.min.js");
71
72use sloc_core::{
73 analyze, compute_delta, compute_multi_delta, read_json, AnalysisRun, CleanupPolicy,
74 CleanupPolicyStore, FileChangeStatus, MultiScanComparison, RegistryEntry, ScanRegistry,
75 ScanSummarySnapshot, SummaryTotals, WatchedDirsStore,
76};
77use sloc_report::{
78 render_html, render_html_with_delta, render_sub_report_html, write_pdf_from_html,
79 write_pdf_from_run, ReportDeltaContext,
80};
81const MAX_CONCURRENT_ANALYSES: usize = 4;
82
83#[cfg(target_os = "windows")]
91#[allow(clippy::upper_case_acronyms)]
92#[allow(dead_code)]
93mod win_dialog_focus {
94 #[cfg(feature = "native-dialog")]
95 use std::mem::size_of;
96
97 type HWND = *mut core::ffi::c_void;
98 type DWORD = u32;
99 type UINT = u32;
100 type BOOL = i32;
101
102 #[cfg(feature = "native-dialog")]
104 #[repr(C)]
105 #[allow(non_snake_case)]
106 struct FLASHWINFO {
107 cbSize: UINT,
108 hwnd: HWND,
109 dwFlags: DWORD,
110 uCount: UINT,
111 dwTimeout: DWORD,
112 }
113
114 #[cfg(feature = "native-dialog")]
115 const FLASHW_ALL: DWORD = 0x3;
116 #[cfg(feature = "native-dialog")]
117 const FLASHW_TIMERNOFG: DWORD = 0xC;
118
119 #[link(name = "user32")]
120 extern "system" {
121 fn GetForegroundWindow() -> HWND;
122 fn SetForegroundWindow(hWnd: HWND) -> BOOL;
123 fn ShowWindow(hWnd: HWND, nCmdShow: i32) -> BOOL;
124 fn BringWindowToTop(hWnd: HWND) -> BOOL;
125 fn SetWindowPos(
126 hWnd: HWND,
127 hWndAfter: HWND,
128 x: i32,
129 y: i32,
130 cx: i32,
131 cy: i32,
132 flags: UINT,
133 ) -> BOOL;
134 fn GetWindowThreadProcessId(hWnd: HWND, lpdwProcessId: *mut DWORD) -> DWORD;
135 fn AttachThreadInput(idAttach: DWORD, idAttachTo: DWORD, fAttach: BOOL) -> BOOL;
136 #[cfg(feature = "native-dialog")]
137 fn FlashWindowEx(pfwi: *const FLASHWINFO) -> BOOL;
138 fn FindWindowW(lpClassName: *const u16, lpWindowName: *const u16) -> HWND;
139 fn FindWindowExW(
140 hWndParent: HWND,
141 hWndChildAfter: HWND,
142 lpszClass: *const u16,
143 lpszWindow: *const u16,
144 ) -> HWND;
145 fn SwitchToThisWindow(hWnd: HWND, fAltTab: BOOL);
149 }
150
151 #[link(name = "kernel32")]
152 extern "system" {
153 fn GetCurrentThreadId() -> DWORD;
154 }
155
156 #[link(name = "shell32")]
157 extern "system" {
158 fn ShellExecuteW(
163 hwnd: HWND,
164 lpOperation: *const u16,
165 lpFile: *const u16,
166 lpParameters: *const u16,
167 lpDirectory: *const u16,
168 nShowCmd: i32,
169 ) -> isize; }
171
172 #[cfg(feature = "native-dialog")]
177 pub fn attach_to_foreground() -> DWORD {
178 unsafe {
179 let fg_hwnd = GetForegroundWindow();
180 if fg_hwnd.is_null() {
181 return 0;
182 }
183 let fg_tid = GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut());
184 let my_tid = GetCurrentThreadId();
185 if fg_tid == my_tid {
186 return 0;
187 }
188 AttachThreadInput(my_tid, fg_tid, 1);
189 fg_tid
190 }
191 }
192
193 #[cfg(feature = "native-dialog")]
195 pub fn detach_from_foreground(fg_tid: DWORD) {
196 if fg_tid == 0 {
197 return;
198 }
199 unsafe {
200 AttachThreadInput(GetCurrentThreadId(), fg_tid, 0);
201 }
202 }
203
204 unsafe fn snapshot_explorer_hwnds(class_w: &[u16]) -> std::collections::HashSet<usize> {
205 let mut existing = std::collections::HashSet::new();
206 let mut prev: HWND = core::ptr::null_mut();
207 loop {
208 let w = FindWindowExW(
209 core::ptr::null_mut(),
210 prev,
211 class_w.as_ptr(),
212 core::ptr::null(),
213 );
214 if w.is_null() {
215 break;
216 }
217 existing.insert(w as usize);
218 prev = w;
219 }
220 existing
221 }
222
223 unsafe fn find_new_explorer_hwnd(
224 class_w: &[u16],
225 existing: &std::collections::HashSet<usize>,
226 ) -> Option<HWND> {
227 let mut prev: HWND = core::ptr::null_mut();
228 loop {
229 let w = FindWindowExW(
230 core::ptr::null_mut(),
231 prev,
232 class_w.as_ptr(),
233 core::ptr::null(),
234 );
235 if w.is_null() {
236 return None;
237 }
238 if !existing.contains(&(w as usize)) {
239 return Some(w);
240 }
241 prev = w;
242 }
243 }
244
245 unsafe fn bring_to_front(hwnd: HWND) {
246 let my_tid = GetCurrentThreadId();
254 let fg_hwnd = GetForegroundWindow();
255 let fg_tid = if fg_hwnd.is_null() {
256 0
257 } else {
258 GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut())
259 };
260 let attached = fg_tid != 0 && fg_tid != my_tid && AttachThreadInput(my_tid, fg_tid, 1) != 0;
261
262 ShowWindow(hwnd, 9);
265 BringWindowToTop(hwnd);
266 SetForegroundWindow(hwnd);
267 SwitchToThisWindow(hwnd, 1);
270
271 SetWindowPos(hwnd, (-1isize) as HWND, 0, 0, 0, 0, 0x0013);
276 SetWindowPos(hwnd, (-2isize) as HWND, 0, 0, 0, 0, 0x0013);
277
278 if attached {
279 AttachThreadInput(my_tid, fg_tid, 0);
280 }
281 }
282
283 pub fn open_folder_foreground(path: std::path::PathBuf) {
290 std::thread::spawn(move || {
291 use std::os::windows::ffi::OsStrExt;
292
293 let op: Vec<u16> = "explore\0".encode_utf16().collect();
294 let mut path_w: Vec<u16> = path.as_os_str().encode_wide().collect();
295 path_w.push(0);
296 let class_w: Vec<u16> = "CabinetWClass\0".encode_utf16().collect();
297
298 unsafe {
299 let existing = snapshot_explorer_hwnds(&class_w);
302 let fg_hwnd = GetForegroundWindow();
303 ShellExecuteW(
305 fg_hwnd,
306 op.as_ptr(),
307 path_w.as_ptr(),
308 core::ptr::null(),
309 core::ptr::null(),
310 1,
311 );
312
313 for _ in 0..40 {
317 std::thread::sleep(std::time::Duration::from_millis(75));
318 if let Some(w) = find_new_explorer_hwnd(&class_w, &existing) {
319 bring_to_front(w);
320 return;
321 }
322 }
323
324 let w = FindWindowW(class_w.as_ptr(), core::ptr::null());
327 if !w.is_null() {
328 bring_to_front(w);
329 }
330 }
331 });
332 }
333
334 #[cfg(feature = "native-dialog")]
338 pub fn flash_dialog_when_ready(title: String) {
339 std::thread::spawn(move || {
340 let title_w: Vec<u16> = title.encode_utf16().chain(core::iter::once(0)).collect();
341 for _ in 0..40 {
342 std::thread::sleep(std::time::Duration::from_millis(80));
343 unsafe {
344 let hwnd = FindWindowW(core::ptr::null(), title_w.as_ptr());
345 if !hwnd.is_null() {
346 SetForegroundWindow(hwnd);
347 BringWindowToTop(hwnd);
348 #[allow(non_snake_case)]
349 FlashWindowEx(&FLASHWINFO {
350 #[allow(clippy::cast_possible_truncation)]
353 cbSize: size_of::<FLASHWINFO>() as UINT,
354 hwnd,
355 dwFlags: FLASHW_ALL | FLASHW_TIMERNOFG,
356 uCount: 3,
357 dwTimeout: 0,
358 });
359 break;
360 }
361 }
362 }
363 });
364 }
365}
366
367pub(crate) struct IpRateLimiter {
370 window: Duration,
371 max_requests: usize,
372 pub(crate) auth_lockout_threshold: u32,
373 auth_lockout_window: Duration,
374 state: std::sync::Mutex<HashMap<IpAddr, VecDeque<Instant>>>,
375 auth_failures: std::sync::Mutex<HashMap<IpAddr, (u32, Instant)>>,
376}
377
378impl IpRateLimiter {
379 pub(crate) fn new(
380 window: Duration,
381 max_requests: usize,
382 auth_lockout_threshold: u32,
383 auth_lockout_window: Duration,
384 ) -> Self {
385 Self {
386 window,
387 max_requests,
388 auth_lockout_threshold,
389 auth_lockout_window,
390 state: std::sync::Mutex::new(HashMap::new()),
391 auth_failures: std::sync::Mutex::new(HashMap::new()),
392 }
393 }
394
395 #[allow(clippy::significant_drop_tightening)]
398 pub(crate) fn is_allowed(&self, ip: IpAddr) -> bool {
399 let now = Instant::now();
400 let cutoff = now.checked_sub(self.window).unwrap_or(now);
401 let mut state = self
402 .state
403 .lock()
404 .unwrap_or_else(std::sync::PoisonError::into_inner);
405 if state.len() > 10_000 {
406 state.retain(|_, bucket| {
407 while bucket.front().is_some_and(|t| *t <= cutoff) {
408 bucket.pop_front();
409 }
410 !bucket.is_empty()
411 });
412 }
413 let bucket = state.entry(ip).or_default();
414 while bucket.front().is_some_and(|t| *t <= cutoff) {
415 bucket.pop_front();
416 }
417 if bucket.len() >= self.max_requests {
418 false
419 } else {
420 bucket.push_back(now);
421 true
422 }
423 }
424
425 pub(crate) fn record_auth_failure(&self, ip: IpAddr) {
426 let now = Instant::now();
427 let mut map = self
428 .auth_failures
429 .lock()
430 .unwrap_or_else(std::sync::PoisonError::into_inner);
431 map.entry(ip)
432 .and_modify(|e| {
433 e.0 += 1;
434 e.1 = now;
435 })
436 .or_insert_with(|| (1, now));
437 }
438
439 pub(crate) fn is_auth_locked_out(&self, ip: IpAddr) -> bool {
440 let mut map = self
441 .auth_failures
442 .lock()
443 .unwrap_or_else(std::sync::PoisonError::into_inner);
444 let expired = map
445 .get(&ip)
446 .is_some_and(|e| e.1.elapsed() > self.auth_lockout_window);
447 if expired {
448 map.remove(&ip);
449 return false;
450 }
451 map.get(&ip)
452 .is_some_and(|e| e.0 >= self.auth_lockout_threshold)
453 }
454
455 pub(crate) fn auth_lockout_remaining_secs(&self, ip: IpAddr) -> u64 {
456 let map = self
457 .auth_failures
458 .lock()
459 .unwrap_or_else(std::sync::PoisonError::into_inner);
460 map.get(&ip).map_or(0, |e| {
461 self.auth_lockout_window
462 .checked_sub(e.1.elapsed())
463 .map_or(0, |r| r.as_secs())
464 })
465 }
466
467 pub(crate) fn spawn_pruning_task(limiter: Arc<Self>) {
468 tokio::spawn(async move {
469 let mut interval = tokio::time::interval(Duration::from_mins(1));
470 interval.tick().await; loop {
472 interval.tick().await;
473 let now = Instant::now();
474 let cutoff = now.checked_sub(limiter.window).unwrap_or(now);
475 {
476 let mut state = limiter
477 .state
478 .lock()
479 .unwrap_or_else(std::sync::PoisonError::into_inner);
480 state.retain(|_, bucket| {
481 while bucket.front().is_some_and(|t| *t <= cutoff) {
482 bucket.pop_front();
483 }
484 !bucket.is_empty()
485 });
486 }
487 {
488 let mut auth = limiter
489 .auth_failures
490 .lock()
491 .unwrap_or_else(std::sync::PoisonError::into_inner);
492 auth.retain(|_, e| e.1.elapsed() <= limiter.auth_lockout_window);
493 }
494 }
495 });
496 }
497}
498
499fn spawn_upload_staging_cleanup() {
503 tokio::spawn(async move {
504 let ttl_hours: u64 = std::env::var("SLOC_UPLOAD_TTL_HOURS")
505 .ok()
506 .and_then(|v| v.parse().ok())
507 .unwrap_or(4);
508 let ttl_secs = ttl_hours * 3600;
509 let mut interval = tokio::time::interval(Duration::from_hours(1));
510 interval.tick().await; loop {
512 interval.tick().await;
513 let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
514 let Ok(mut dir) = tokio::fs::read_dir(&upload_root).await else {
515 continue;
516 };
517 while let Ok(Some(entry)) = dir.next_entry().await {
518 let path = entry.path();
519 let age_secs = tokio::fs::metadata(&path)
520 .await
521 .ok()
522 .and_then(|m| m.modified().ok())
523 .and_then(|t| t.elapsed().ok())
524 .map_or(0, |d| d.as_secs());
525 if age_secs > ttl_secs {
526 tracing::debug!(
527 event = "upload_staging_cleanup",
528 path = %path.display(),
529 age_secs,
530 "removing stale upload staging directory"
531 );
532 let _ = tokio::fs::remove_dir_all(&path).await;
533 }
534 }
535 }
536 });
537}
538
539#[derive(Clone, Debug, Default)]
541struct RunResultContext {
542 prev_entry: Option<RegistryEntry>,
543 prev_scan_count: usize,
544 project_path: String,
545 cocomo_mode: String,
547 complexity_alert: u32,
549 #[allow(dead_code)]
551 exclude_duplicates: bool,
552}
553
554#[derive(Clone)]
556enum AsyncRunState {
557 Running {
558 started_at: std::time::Instant,
559 cancel_token: Arc<std::sync::atomic::AtomicBool>,
560 phase: Arc<std::sync::Mutex<String>>,
561 files_done: Arc<std::sync::atomic::AtomicUsize>,
562 files_total: Arc<std::sync::atomic::AtomicUsize>,
563 },
564 Complete {
566 run_id: String,
567 },
568 Failed {
569 message: String,
570 },
571 Cancelled,
572}
573
574#[derive(Debug, Clone, Serialize, Deserialize)]
577struct ScanProfile {
578 id: String,
579 name: String,
580 created_at: String,
581 params: serde_json::Value,
583}
584
585#[derive(Debug, Clone, Default, Serialize, Deserialize)]
586struct ScanProfileStore {
587 profiles: Vec<ScanProfile>,
588}
589
590impl ScanProfileStore {
591 fn load(path: &std::path::Path) -> Self {
592 fs::read_to_string(path)
593 .ok()
594 .and_then(|s| serde_json::from_str(&s).ok())
595 .unwrap_or_default()
596 }
597
598 fn save(&self, path: &std::path::Path) -> anyhow::Result<()> {
599 if let Some(parent) = path.parent() {
600 fs::create_dir_all(parent)?;
601 }
602 let json = serde_json::to_string_pretty(self)?;
603 fs::write(path, json)?;
604 Ok(())
605 }
606}
607
608#[derive(Clone, Copy)]
611pub(crate) struct SessionState {
612 pub(crate) absolute_expiry: Instant,
613 pub(crate) last_seen: Instant,
614}
615
616#[derive(Clone)]
617pub(crate) struct AppState {
618 pub(crate) base_config: AppConfig,
619 pub(crate) artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
620 pub(crate) async_runs: Arc<Mutex<HashMap<String, AsyncRunState>>>,
621 pub(crate) registry: Arc<Mutex<ScanRegistry>>,
622 pub(crate) registry_path: PathBuf,
623 pub(crate) analyze_semaphore: Arc<tokio::sync::Semaphore>,
624 pub(crate) server_mode: bool,
625 pub(crate) allow_unauthenticated: bool,
629 pub(crate) tls_enabled: bool,
630 pub(crate) api_keys: Arc<Vec<secrecy::SecretBox<String>>>,
631 pub(crate) readonly_api_keys: Arc<Vec<secrecy::SecretBox<String>>>,
635 pub(crate) rate_limiter: Arc<IpRateLimiter>,
636 pub(crate) trust_proxy: bool,
637 pub(crate) trusted_proxy_ips: Vec<IpAddr>,
640 pub(crate) git_clones_dir: PathBuf,
642 pub(crate) schedules: Arc<Mutex<ScheduleStore>>,
644 pub(crate) schedules_path: PathBuf,
645 pub(crate) scan_profiles: Arc<Mutex<ScanProfileStore>>,
647 pub(crate) scan_profiles_path: PathBuf,
648 pub(crate) sessions: Arc<std::sync::Mutex<HashMap<String, SessionState>>>,
649 pub(crate) confluence: Arc<Mutex<confluence::ConfluenceConfigStore>>,
651 pub(crate) confluence_path: PathBuf,
652 pub(crate) watched_dirs: Arc<Mutex<WatchedDirsStore>>,
654 pub(crate) watched_dirs_path: PathBuf,
655 pub(crate) cleanup_policy: Arc<Mutex<CleanupPolicyStore>>,
657 pub(crate) cleanup_policy_path: PathBuf,
658 pub(crate) cleanup_task_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
660}
661
662type PendingPdf = Option<(PathBuf, PathBuf, bool)>;
663
664#[derive(Clone, Debug)]
667pub(crate) struct RunArtifacts {
668 output_dir: PathBuf,
669 html_path: Option<PathBuf>,
670 pdf_path: Option<PathBuf>,
671 json_path: Option<PathBuf>,
672 csv_path: Option<PathBuf>,
673 xlsx_path: Option<PathBuf>,
674 scan_config_path: Option<PathBuf>,
675 report_title: String,
676 result_context: RunResultContext,
677}
678
679#[allow(clippy::too_many_lines)] fn build_router(state: AppState) -> Router {
681 let protected = Router::new()
682 .route("/", get(splash))
683 .route("/scan-setup", get(scan_setup_handler))
684 .route("/scan", get(index))
685 .route("/analyze", post(analyze_handler))
686 .route("/preview", get(preview_handler))
687 .route("/api/suggest-coverage", get(api_suggest_coverage))
688 .route("/pick-directory", get(pick_directory_handler))
689 .route("/open-path", get(open_path_handler))
690 .route("/pick-file", get(pick_file_handler))
691 .route(
692 "/api/upload-directory",
693 post(upload_directory_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
694 )
695 .route(
696 "/api/upload-file",
697 post(upload_file_handler).layer(DefaultBodyLimit::max(30 * 1024 * 1024)),
698 )
699 .route(
700 "/api/upload-tarball",
701 post(upload_tarball_handler)
704 .layer(DefaultBodyLimit::max(tarball_http_body_limit_bytes())),
705 )
706 .route("/locate-report", post(locate_report_handler))
707 .route("/locate-reports-dir", post(locate_reports_dir_handler))
708 .route("/relocate-scan", post(relocate_scan_handler))
709 .route("/watched-dirs/add", post(add_watched_dir_handler))
710 .route("/watched-dirs/remove", post(remove_watched_dir_handler))
711 .route("/watched-dirs/refresh", post(refresh_watched_dirs_handler))
712 .route("/view-reports", get(history_handler))
713 .route("/compare-scans", get(compare_select_handler))
714 .route("/compare", get(compare_handler))
715 .route("/multi-compare", get(multi_compare_handler))
716 .route("/images/{folder}/{file}", get(image_handler))
717 .route("/runs/{artifact}/{run_id}", get(artifact_handler))
718 .route("/api/metrics/latest", get(api_metrics_latest_handler))
719 .route("/api/metrics/{run_id}", get(api_metrics_run_handler))
720 .route("/api/metrics/history", get(api_metrics_history_handler))
721 .route("/api/metrics/churn", get(api_metrics_churn_handler))
722 .route(
723 "/api/metrics/submodules",
724 get(api_metrics_submodules_handler),
725 )
726 .route("/api/ingest", post(api_ingest_handler))
727 .route("/api/project-history", get(project_history_handler))
728 .route("/trend-reports", get(trend_report_handler))
729 .route("/test-metrics", get(test_metrics_handler))
730 .route("/api/runs/{wait_id}/status", get(async_run_status_handler))
731 .route("/api/runs/{wait_id}/cancel", post(cancel_run_handler))
732 .route("/api/runs/{run_id}/pdf-status", get(pdf_status_handler))
733 .route("/runs/result/{run_id}", get(async_run_result_handler))
734 .route("/embed/summary", get(embed_handler))
735 .route("/git-browser", get(git_browser::git_browser_handler))
737 .route("/api/git/refs", get(git_browser::api_list_refs))
738 .route("/api/git/scan-ref", get(git_browser::api_scan_ref))
739 .route("/api/git/compare-refs", get(git_browser::api_compare_refs))
740 .route(
745 "/export/pdf",
746 post(export_pdf_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
747 )
748 .route("/export-config", get(export_config_handler))
750 .route("/import-config", post(import_config_handler))
751 .route("/api/scan-profiles", get(api_list_scan_profiles))
753 .route("/api/scan-profiles", post(api_save_scan_profile))
754 .route(
755 "/api/scan-profiles/{id}",
756 axum::routing::delete(api_delete_scan_profile),
757 )
758 .route("/integrations", get(integrations::integrations_handler))
760 .route(
761 "/webhook-setup",
762 get(|| async { axum::response::Redirect::permanent("/integrations") }),
763 )
764 .route(
765 "/confluence-setup",
766 get(|| async { axum::response::Redirect::permanent("/integrations#confluence") }),
767 )
768 .route("/api/schedules", get(git_webhook::api_list_schedules))
769 .route("/api/schedules", post(git_webhook::api_create_schedule))
770 .route(
771 "/api/schedules",
772 axum::routing::delete(git_webhook::api_delete_schedule),
773 )
774 .route(
775 "/api/confluence/config",
776 get(confluence::api_get_confluence_config),
777 )
778 .route(
779 "/api/confluence/config",
780 post(confluence::api_save_confluence_config),
781 )
782 .route(
783 "/api/confluence/test",
784 post(confluence::api_test_confluence),
785 )
786 .route(
787 "/api/confluence/post",
788 post(confluence::api_post_to_confluence),
789 )
790 .route(
791 "/api/confluence/wiki-markup",
792 get(confluence::api_wiki_markup),
793 )
794 .route("/api/runs/{run_id}/bundle", get(download_bundle_handler))
796 .route(
797 "/api/runs/{run_id}",
798 axum::routing::delete(delete_run_handler),
799 )
800 .route("/api/runs/cleanup", post(cleanup_runs_handler))
801 .route(
803 "/api/cleanup-policy",
804 get(api_get_cleanup_policy)
805 .post(api_save_cleanup_policy)
806 .delete(api_delete_cleanup_policy),
807 )
808 .route("/api/cleanup-policy/run-now", post(api_run_cleanup_now))
809 .route("/api-docs", get(api_docs_handler))
811 .route("/metrics", get(metrics_handler))
813 .route_layer(middleware::from_fn_with_state(
814 state.clone(),
815 auth::require_api_key,
816 ));
817
818 protected
819 .route("/healthz", get(healthz))
820 .route("/api/health", get(healthz))
821 .route("/api/version", get(api_version_handler))
822 .route("/api/openapi.yaml", get(openapi_yaml_handler))
823 .route("/llms.txt", get(llms_txt_handler))
824 .route("/llms-full.txt", get(llms_full_txt_handler))
825 .route("/badge/{metric}", get(badge_handler))
826 .route("/static/chart.js", get(chart_js_handler))
827 .route("/static/chart-report.js", get(report_chart_js_handler))
828 .route("/auth/login", get(auth::auth_login_get))
829 .route("/auth/login", post(auth::auth_login_post))
830 .route("/auth/logout", post(auth::auth_logout))
831 .route("/auth/consent", get(auth::auth_consent_accept))
833 .route(
836 "/webhooks/github",
837 post(git_webhook::handle_github_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
838 )
839 .route(
840 "/webhooks/gitlab",
841 post(git_webhook::handle_gitlab_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
842 )
843 .route(
844 "/webhooks/bitbucket",
845 post(git_webhook::handle_bitbucket_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
846 )
847 .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
848 .layer(middleware::from_fn(consent_gate))
849 .layer(middleware::from_fn(csrf_protect))
850 .layer(middleware::from_fn_with_state(
851 state.clone(),
852 add_security_headers,
853 ))
854 .layer(build_cors_layer(state.server_mode))
855 .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
856 .with_state(state)
857}
858
859pub const TEST_SERVER_MODE_API_KEY: &str = "oxide-sloc-test-server-mode-internal-key";
862
863fn test_app_state(tmp_subdir: &str) -> AppState {
869 std::env::set_var("SLOC_HEADLESS", "1");
870 static TEST_DIR_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
877 let seq = TEST_DIR_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
878 let tmp = std::env::temp_dir().join(format!("{tmp_subdir}-{}-{seq}", std::process::id()));
879 AppState {
880 base_config: AppConfig::default(),
881 artifacts: Arc::new(Mutex::new(HashMap::new())),
882 async_runs: Arc::new(Mutex::new(HashMap::new())),
883 registry: Arc::new(Mutex::new(ScanRegistry::default())),
884 registry_path: tmp.join("registry.json"),
885 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
886 server_mode: false,
887 allow_unauthenticated: false,
888 tls_enabled: false,
889 api_keys: Arc::new(vec![]),
890 readonly_api_keys: Arc::new(vec![]),
891 rate_limiter: Arc::new(IpRateLimiter::new(
892 Duration::from_mins(1),
893 600,
894 10,
895 Duration::from_hours(1),
896 )),
897 trust_proxy: false,
898 trusted_proxy_ips: vec![],
899 git_clones_dir: tmp.join("git-clones"),
900 schedules: Arc::new(Mutex::new(ScheduleStore::default())),
901 schedules_path: tmp.join("schedules.json"),
902 scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
903 scan_profiles_path: tmp.join("scan_profiles.json"),
904 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
905 confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
906 confluence_path: tmp.join("confluence_config.json"),
907 watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
908 watched_dirs_path: tmp.join("watched_dirs.json"),
909 cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
910 cleanup_policy_path: tmp.join("cleanup_policy.json"),
911 cleanup_task_handle: Arc::new(Mutex::new(None)),
912 }
913}
914
915pub fn make_test_router() -> Router {
917 build_router(test_app_state("sloc_test"))
918}
919
920pub fn make_test_router_with_key(api_key: &str) -> Router {
922 let mut state = test_app_state("sloc_test_key");
923 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]);
924 build_router(state)
925}
926
927pub fn make_test_router_server_mode() -> Router {
931 let mut state = test_app_state("sloc_test_server");
932 state.server_mode = true;
933 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
934 TEST_SERVER_MODE_API_KEY.to_owned(),
935 ))]);
936 build_router(state)
937}
938
939pub fn make_test_router_server_mode_with_roots(roots: Vec<PathBuf>) -> Router {
945 let mut state = test_app_state("sloc_test_server_roots");
946 state.server_mode = true;
947 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
948 TEST_SERVER_MODE_API_KEY.to_owned(),
949 ))]);
950 state.base_config.discovery.allowed_scan_roots = roots;
951 build_router(state)
952}
953
954pub fn make_test_router_exhausted_semaphore() -> Router {
957 let mut state = test_app_state("sloc_test_exhaust");
958 state.analyze_semaphore = Arc::new(tokio::sync::Semaphore::new(0));
959 build_router(state)
960}
961
962pub fn make_test_router_tight_rate_limit() -> Router {
965 let mut state = test_app_state("sloc_test_rate");
966 state.rate_limiter = Arc::new(IpRateLimiter::new(
967 Duration::from_mins(1),
968 2,
969 5,
970 Duration::from_secs(5),
971 ));
972 build_router(state)
973}
974
975pub fn make_test_router_tight_auth_lockout(api_key: &str) -> Router {
978 let mut state = test_app_state("sloc_test_auth_lockout");
979 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]);
980 state.rate_limiter = Arc::new(IpRateLimiter::new(
981 Duration::from_mins(1),
982 600,
983 2, Duration::from_millis(200), ));
986 build_router(state)
987}
988
989struct RuntimeSecurityConfig {
990 api_keys: Vec<secrecy::SecretBox<String>>,
991 readonly_api_keys: Vec<secrecy::SecretBox<String>>,
992 tls_cert: Option<String>,
993 tls_key: Option<String>,
994 tls_enabled: bool,
995 trust_proxy: bool,
996 trusted_proxy_ips: Vec<IpAddr>,
997 rate_limiter: Arc<IpRateLimiter>,
998}
999
1000fn allow_unauthenticated_server_mode() -> bool {
1003 matches!(
1004 std::env::var("SLOC_ALLOW_UNAUTHENTICATED").as_deref(),
1005 Ok("1") | Ok("true") | Ok("TRUE")
1006 )
1007}
1008
1009fn refuse_unauthenticated_server(server_mode: bool, has_api_keys: bool) -> bool {
1013 server_mode && !has_api_keys && !allow_unauthenticated_server_mode()
1014}
1015
1016fn hardened_mode() -> bool {
1021 std::env::var("SLOC_HARDENED").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1022}
1023
1024fn require_tls() -> bool {
1028 hardened_mode()
1029 || std::env::var("SLOC_REQUIRE_TLS")
1030 .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1031}
1032
1033pub(crate) fn session_idle_timeout() -> Option<Duration> {
1039 match std::env::var("SLOC_SESSION_IDLE_SECS")
1040 .ok()
1041 .and_then(|v| v.parse::<u64>().ok())
1042 {
1043 Some(0) => None,
1044 Some(secs) => Some(Duration::from_secs(secs)),
1045 None if hardened_mode() => Some(Duration::from_secs(15 * 60)),
1046 None => None,
1047 }
1048}
1049
1050const DEFAULT_CONSENT_NOTICE: &str = "This is a restricted system for authorized users only. \
1053Activity on this system may be monitored and recorded. By continuing you acknowledge that you \
1054are an authorized user and consent to such monitoring. Unauthorized use is prohibited.";
1055
1056fn consent_banner_text() -> Option<String> {
1060 if let Ok(t) = std::env::var("SLOC_CONSENT_BANNER") {
1061 let t = t.trim();
1062 if !t.is_empty() {
1063 return Some(t.to_owned());
1064 }
1065 }
1066 hardened_mode().then(|| DEFAULT_CONSENT_NOTICE.to_owned())
1067}
1068
1069fn consent_gate_applies(req: &Request<Body>) -> bool {
1073 if !matches!(
1074 *req.method(),
1075 axum::http::Method::GET | axum::http::Method::HEAD
1076 ) {
1077 return false;
1078 }
1079 let is_html = req
1080 .headers()
1081 .get(header::ACCEPT)
1082 .and_then(|v| v.to_str().ok())
1083 .is_some_and(|a| a.contains("text/html"));
1084 if !is_html {
1085 return false;
1086 }
1087 let path = req.uri().path();
1088 const EXEMPT: &[&str] = &[
1089 "/auth/consent",
1090 "/static/",
1091 "/images/",
1092 "/assets/",
1093 "/badge/",
1094 "/healthz",
1095 "/api/",
1096 "/webhooks/",
1097 "/metrics",
1098 "/favicon",
1099 "/llms",
1100 ];
1101 !EXEMPT.iter().any(|p| path.starts_with(p))
1102}
1103
1104fn request_has_consent(req: &Request<Body>) -> bool {
1106 req.headers()
1107 .get(header::COOKIE)
1108 .and_then(|v| v.to_str().ok())
1109 .is_some_and(|c| c.split(';').any(|p| p.trim() == "sloc_consent=1"))
1110}
1111
1112async fn consent_gate(req: Request<Body>, next: Next) -> Response {
1116 let Some(text) = consent_banner_text() else {
1117 return next.run(req).await;
1118 };
1119 if !consent_gate_applies(&req) || request_has_consent(&req) {
1120 return next.run(req).await;
1121 }
1122 let next_path = req.uri().path_and_query().map_or("/", |pq| pq.as_str());
1123 render_consent_page(&text, next_path)
1124}
1125
1126fn html_escape_consent(s: &str) -> String {
1128 s.replace('&', "&")
1129 .replace('<', "<")
1130 .replace('>', ">")
1131 .replace('"', """)
1132}
1133
1134fn render_consent_page(text: &str, next_path: &str) -> Response {
1137 let safe_next = if next_path.starts_with('/')
1139 && !next_path.starts_with("//")
1140 && !next_path.contains("://")
1141 && !next_path.starts_with("/auth/")
1142 {
1143 next_path
1144 } else {
1145 "/"
1146 };
1147 let accept_url = format!("/auth/consent?next={}", html_escape_consent(safe_next));
1148 let body = format!(
1149 r#"<!doctype html><html><head><meta charset="utf-8">
1150<meta name="viewport" content="width=device-width, initial-scale=1">
1151<title>Notice and Consent — OxideSLOC</title>
1152<style>body{{font-family:system-ui,sans-serif;max-width:560px;margin:64px auto;padding:0 24px;color:#2f241c}}
1153h1{{color:#b85d33;font-size:20px}}.notice{{line-height:1.65;background:#f7efe7;border:1px solid #e2d2c2;border-radius:10px;padding:18px 20px;white-space:pre-wrap}}
1154.agree{{display:inline-block;margin-top:20px;background:#b85d33;color:#fff;text-decoration:none;padding:10px 22px;border-radius:8px;font-weight:700}}
1155.agree:hover{{background:#a04d27}}</style>
1156</head><body>
1157<h1>Notice and Consent</h1>
1158<div class="notice">{}</div>
1159<a class="agree" href="{}">I Agree</a>
1160</body></html>"#,
1161 html_escape_consent(text),
1162 accept_url
1163 );
1164 (StatusCode::OK, Html(body)).into_response()
1165}
1166
1167fn emit_server_mode_warnings(
1170 server_mode: bool,
1171 api_keys_empty: bool,
1172 tls_enabled: bool,
1173 trust_proxy: bool,
1174 trusted_proxy_ips: &[IpAddr],
1175) {
1176 if server_mode && api_keys_empty && allow_unauthenticated_server_mode() {
1177 println!(
1181 "WARNING: SLOC_ALLOW_UNAUTHENTICATED=1 — server mode is running with NO \
1182 authentication. Every web endpoint is publicly reachable. Do NOT use this \
1183 outside a trusted, isolated network."
1184 );
1185 }
1186 if server_mode && !tls_enabled {
1187 println!(
1188 "WARNING: TLS is not configured. Traffic is cleartext. \
1189 Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
1190 or terminate TLS at a reverse proxy (nginx, caddy)."
1191 );
1192 }
1193 if server_mode {
1194 println!(
1195 "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
1196 to restrict cross-origin access (comma-separated)."
1197 );
1198 }
1199 emit_trust_proxy_note(server_mode, trust_proxy, trusted_proxy_ips);
1200 if std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some() {
1201 println!(
1202 "WARNING: SLOC_GIT_SSL_NO_VERIFY is set — TLS certificate verification is \
1203 DISABLED for all git operations. Remove this variable before production use."
1204 );
1205 }
1206}
1207
1208fn emit_trust_proxy_note(server_mode: bool, trust_proxy: bool, trusted_proxy_ips: &[IpAddr]) {
1210 if trust_proxy {
1211 if trusted_proxy_ips.is_empty() {
1212 println!(
1213 "WARNING: SLOC_TRUST_PROXY=1 but SLOC_TRUSTED_PROXY_IPS is not set. \
1214 X-Forwarded-For will NOT be trusted until you specify the proxy IP(s) via \
1215 SLOC_TRUSTED_PROXY_IPS=192.168.1.1,10.0.0.1 to prevent rate-limit bypass."
1216 );
1217 } else {
1218 println!(
1219 "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For is trusted from proxy IPs: {}",
1220 trusted_proxy_ips
1221 .iter()
1222 .map(std::string::ToString::to_string)
1223 .collect::<Vec<_>>()
1224 .join(", ")
1225 );
1226 }
1227 } else if server_mode {
1228 println!(
1229 "NOTE: SLOC_TRUST_PROXY is not set. If oxide-sloc is behind a reverse proxy \
1230 (nginx, Caddy, Traefik), all LAN clients share one rate-limit bucket (the \
1231 proxy IP). Set SLOC_TRUST_PROXY=1 and SLOC_TRUSTED_PROXY_IPS=<proxy-ip> to \
1232 enable per-client rate limiting via X-Forwarded-For."
1233 );
1234 }
1235}
1236
1237fn load_runtime_security_config(server_mode: bool) -> RuntimeSecurityConfig {
1238 let api_keys: Vec<secrecy::SecretBox<String>> = std::env::var("SLOC_API_KEYS")
1239 .or_else(|_| std::env::var("SLOC_API_KEY"))
1240 .unwrap_or_default()
1241 .split(',')
1242 .map(str::trim)
1243 .filter(|s| !s.is_empty())
1244 .map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
1245 .collect();
1246 let readonly_api_keys: Vec<secrecy::SecretBox<String>> =
1247 std::env::var("SLOC_API_KEYS_READONLY")
1248 .unwrap_or_default()
1249 .split(',')
1250 .map(str::trim)
1251 .filter(|s| !s.is_empty())
1252 .map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
1253 .collect();
1254 let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
1255 let tls_key = std::env::var("SLOC_TLS_KEY").ok();
1256 let tls_enabled = tls_cert.is_some() && tls_key.is_some();
1257 let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
1258 let trusted_proxy_ips: Vec<IpAddr> = std::env::var("SLOC_TRUSTED_PROXY_IPS")
1259 .unwrap_or_default()
1260 .split(',')
1261 .filter_map(|s| s.trim().parse::<IpAddr>().ok())
1262 .collect();
1263 emit_server_mode_warnings(
1264 server_mode,
1265 api_keys.is_empty(),
1266 tls_enabled,
1267 trust_proxy,
1268 &trusted_proxy_ips,
1269 );
1270 let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
1271 .ok()
1272 .and_then(|v| v.parse::<u32>().ok())
1273 .unwrap_or_else(|| if hardened_mode() { 3 } else { 10 });
1274 let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
1275 .ok()
1276 .and_then(|v| v.parse::<u64>().ok())
1277 .unwrap_or(3600);
1278 let default_rpm: usize = if server_mode { 120 } else { 600 };
1282 let rate_limit_rpm = std::env::var("SLOC_RATE_LIMIT")
1283 .ok()
1284 .and_then(|v| v.parse::<usize>().ok())
1285 .unwrap_or(default_rpm);
1286 let rate_limiter = Arc::new(IpRateLimiter::new(
1287 Duration::from_mins(1),
1288 rate_limit_rpm,
1289 auth_lockout_threshold,
1290 Duration::from_secs(auth_lockout_secs),
1291 ));
1292 IpRateLimiter::spawn_pruning_task(Arc::clone(&rate_limiter));
1293 RuntimeSecurityConfig {
1294 api_keys,
1295 readonly_api_keys,
1296 tls_cert,
1297 tls_key,
1298 tls_enabled,
1299 trust_proxy,
1300 trusted_proxy_ips,
1301 rate_limiter,
1302 }
1303}
1304
1305#[allow(clippy::too_many_lines)]
1314pub async fn serve(config: AppConfig) -> Result<()> {
1315 let bind_address = config.web.bind_address.clone();
1316 let server_mode = config.web.server_mode;
1317 let output_root = resolve_output_root(None);
1318 let registry_path = std::env::var("SLOC_REGISTRY_PATH")
1320 .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
1321 let mut registry = ScanRegistry::load(®istry_path);
1322 registry.prune_stale();
1323 let _ = registry.save(®istry_path);
1324
1325 let sec = load_runtime_security_config(server_mode);
1326 if refuse_unauthenticated_server(server_mode, !sec.api_keys.is_empty()) {
1330 audit::record(
1331 "server_start_refused",
1332 "denied",
1333 &[(
1334 "reason",
1335 "server mode requires SLOC_API_KEY / SLOC_API_KEYS",
1336 )],
1337 );
1338 anyhow::bail!(
1339 "refusing to start: server mode requires authentication. Set SLOC_API_KEY \
1340 (or SLOC_API_KEYS=<k1,k2>) to a secret before launching. To run an \
1341 unauthenticated server on a trusted, isolated network, explicitly set \
1342 SLOC_ALLOW_UNAUTHENTICATED=1 (not recommended)."
1343 );
1344 }
1345 if server_mode && sec.api_keys.is_empty() {
1346 audit::record("server_start_unauthenticated", "warning", &[]);
1347 }
1348 spawn_upload_staging_cleanup();
1349
1350 let git_clones_dir = resolve_git_clones_dir(&output_root);
1351 let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
1352 .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
1353 let schedules = ScheduleStore::load(&schedules_path);
1354 let scan_profiles_path = std::env::var("SLOC_SCAN_PROFILES_PATH")
1355 .map_or_else(|_| output_root.join("scan_profiles.json"), PathBuf::from);
1356 let scan_profiles = ScanProfileStore::load(&scan_profiles_path);
1357 let confluence_path = std::env::var("SLOC_CONFLUENCE_CONFIG_PATH").map_or_else(
1358 |_| output_root.join("confluence_config.json"),
1359 PathBuf::from,
1360 );
1361 let confluence = confluence::ConfluenceConfigStore::load(&confluence_path);
1362 let watched_dirs_path = std::env::var("SLOC_WATCHED_DIRS_PATH")
1363 .map_or_else(|_| output_root.join("watched_dirs.json"), PathBuf::from);
1364 let watched_dirs = WatchedDirsStore::load(&watched_dirs_path);
1365 let cleanup_policy_path = std::env::var("SLOC_CLEANUP_POLICY_PATH")
1366 .map_or_else(|_| output_root.join("cleanup_policy.json"), PathBuf::from);
1367 let cleanup_policy = CleanupPolicyStore::load(&cleanup_policy_path);
1368
1369 let state = AppState {
1370 base_config: config,
1371 artifacts: Arc::new(Mutex::new(HashMap::new())),
1372 async_runs: Arc::new(Mutex::new(HashMap::new())),
1373 registry: Arc::new(Mutex::new(registry)),
1374 registry_path,
1375 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
1376 server_mode,
1377 allow_unauthenticated: allow_unauthenticated_server_mode(),
1378 tls_enabled: sec.tls_enabled,
1379 api_keys: Arc::new(sec.api_keys),
1380 readonly_api_keys: Arc::new(sec.readonly_api_keys),
1381 rate_limiter: sec.rate_limiter,
1382 trust_proxy: sec.trust_proxy,
1383 trusted_proxy_ips: sec.trusted_proxy_ips,
1384 git_clones_dir,
1385 schedules: Arc::new(Mutex::new(schedules)),
1386 schedules_path,
1387 scan_profiles: Arc::new(Mutex::new(scan_profiles)),
1388 scan_profiles_path,
1389 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
1390 confluence: Arc::new(Mutex::new(confluence)),
1391 confluence_path,
1392 watched_dirs: Arc::new(Mutex::new(watched_dirs)),
1393 watched_dirs_path,
1394 cleanup_policy: Arc::new(Mutex::new(cleanup_policy)),
1395 cleanup_policy_path,
1396 cleanup_task_handle: Arc::new(Mutex::new(None)),
1397 };
1398
1399 restart_poll_schedules(&state).await;
1400 warn_insecure_gitlab_webhooks(&state).await;
1401
1402 {
1404 let enabled = state
1405 .cleanup_policy
1406 .lock()
1407 .await
1408 .policy
1409 .as_ref()
1410 .is_some_and(|p| p.enabled);
1411 if enabled {
1412 let handle = spawn_cleanup_policy_task(state.clone());
1413 *state.cleanup_task_handle.lock().await = Some(handle);
1414 }
1415 }
1416
1417 let app = build_router(state.clone());
1418
1419 let preferred: SocketAddr = bind_address
1424 .parse()
1425 .with_context(|| format!("invalid bind address: {bind_address}"))?;
1426
1427 if require_tls() && !preferred.ip().is_loopback() && !sec.tls_enabled {
1432 audit::record(
1433 "server_start_refused",
1434 "denied",
1435 &[("reason", "TLS required for non-loopback bind")],
1436 );
1437 anyhow::bail!(
1438 "refusing to start: TLS is required for a network-facing bind ({preferred}) but \
1439 SLOC_TLS_CERT / SLOC_TLS_KEY are not set. Provide a certificate and key, bind to \
1440 a loopback address, or unset SLOC_REQUIRE_TLS / SLOC_HARDENED."
1441 );
1442 }
1443
1444 let (listener, addr) = {
1445 let candidates = (0u16..=9).map(|offset| {
1446 let mut a = preferred;
1447 a.set_port(preferred.port().saturating_add(offset));
1448 a
1449 });
1450 let mut found = None;
1451 for candidate in candidates {
1452 if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
1453 found = Some((l, candidate));
1454 break;
1455 }
1456 }
1457 found.ok_or_else(|| {
1458 anyhow::anyhow!(
1459 "failed to bind local web UI on {} (tried ports {}-{}): all in use",
1460 bind_address,
1461 preferred.port(),
1462 preferred.port().saturating_add(9)
1463 )
1464 })?
1465 };
1466 if addr != preferred {
1467 eprintln!(
1468 "NOTE: port {} is blocked by a system socket (Windows zombie); \
1469 using {} instead.",
1470 preferred.port(),
1471 addr.port()
1472 );
1473 }
1474
1475 if sec.tls_enabled {
1476 let cert_path = sec
1477 .tls_cert
1478 .expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
1479 let key_path = sec
1480 .tls_key
1481 .expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
1482 let tls_config = build_tls_config(&cert_path, &key_path)
1483 .context("failed to load TLS certificate/key")?;
1484 let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
1485
1486 let url = format!("https://{addr}/");
1487 println!("OxideSLOC server running at {url} (TLS)");
1488 println!("Use Ctrl+C to stop.");
1489
1490 return serve_tls(listener, app, acceptor, server_mode).await;
1491 }
1492
1493 let url = format!("http://{addr}/");
1494 log_startup_url(&url, server_mode);
1495
1496 axum::serve(
1497 listener,
1498 app.into_make_service_with_connect_info::<SocketAddr>(),
1499 )
1500 .with_graceful_shutdown(shutdown_signal(server_mode))
1501 .await
1502 .context("web server terminated unexpectedly")
1503}
1504
1505fn primary_lan_ip() -> Option<String> {
1509 let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
1510 socket.connect("8.8.8.8:80").ok()?;
1511 let addr = socket.local_addr().ok()?;
1512 let ip = addr.ip();
1513 if ip.is_loopback() {
1514 return None;
1515 }
1516 Some(ip.to_string())
1517}
1518
1519fn log_startup_url(url: &str, server_mode: bool) {
1521 if server_mode {
1522 println!("OxideSLOC server running at {url}");
1523 println!("Use Ctrl+C to stop.");
1524 } else {
1525 println!("OxideSLOC local web UI running at {url}");
1526 println!("Press Ctrl+C to stop the server.");
1527 let open_url = url.to_owned();
1528 tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
1529 }
1530}
1531
1532fn open_browser_tab(url: &str) {
1534 #[cfg(target_os = "windows")]
1539 let _ = std::process::Command::new("rundll32")
1540 .args(["url.dll,FileProtocolHandler", url])
1541 .stdout(Stdio::null())
1542 .stderr(Stdio::null())
1543 .spawn();
1544 #[cfg(target_os = "macos")]
1545 let _ = std::process::Command::new("open")
1546 .arg(url)
1547 .stdout(Stdio::null())
1548 .stderr(Stdio::null())
1549 .spawn();
1550 #[cfg(target_os = "linux")]
1551 let _ = std::process::Command::new("xdg-open")
1552 .arg(url)
1553 .stdout(Stdio::null())
1554 .stderr(Stdio::null())
1555 .spawn();
1556}
1557
1558async fn shutdown_signal(server_mode: bool) {
1560 if tokio::signal::ctrl_c().await.is_ok() {
1561 println!();
1562 if server_mode {
1563 println!("Shutting down OxideSLOC server...");
1564 } else {
1565 println!("Shutting down OxideSLOC local web UI...");
1566 }
1567 println!("Server stopped cleanly.");
1568 }
1569}
1570
1571fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
1573 use rustls_pki_types::pem::PemObject;
1574 use rustls_pki_types::{CertificateDer, PrivateKeyDer};
1575
1576 let cert_bytes =
1577 fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
1578 let key_bytes =
1579 fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
1580
1581 let cert_chain: Vec<CertificateDer<'static>> =
1582 CertificateDer::pem_slice_iter(cert_bytes.as_slice())
1583 .collect::<std::result::Result<_, _>>()
1584 .context("failed to parse TLS certificates")?;
1585
1586 let key = PrivateKeyDer::from_pem_slice(key_bytes.as_slice())
1587 .context("failed to parse TLS private key")?;
1588
1589 let builder = rustls::ServerConfig::builder_with_protocol_versions(&[
1594 &rustls::version::TLS13,
1595 &rustls::version::TLS12,
1596 ]);
1597
1598 let config = match client_cert_verifier()? {
1603 Some(verifier) => builder
1604 .with_client_cert_verifier(verifier)
1605 .with_single_cert(cert_chain, key),
1606 None => builder
1607 .with_no_client_auth()
1608 .with_single_cert(cert_chain, key),
1609 };
1610 config.context("failed to build TLS server config")
1611}
1612
1613fn client_cert_verifier() -> Result<Option<Arc<dyn rustls::server::danger::ClientCertVerifier>>> {
1616 use rustls_pki_types::pem::PemObject;
1617 use rustls_pki_types::CertificateDer;
1618
1619 let Some(ca_path) = std::env::var("SLOC_TLS_CLIENT_CA")
1620 .ok()
1621 .filter(|s| !s.is_empty())
1622 else {
1623 return Ok(None);
1624 };
1625 let ca_bytes = fs::read(&ca_path)
1626 .with_context(|| format!("failed to read client CA bundle: {ca_path}"))?;
1627 let mut roots = rustls::RootCertStore::empty();
1628 for cert in CertificateDer::pem_slice_iter(ca_bytes.as_slice()) {
1629 let cert = cert.context("failed to parse client CA certificate")?;
1630 roots
1631 .add(cert)
1632 .context("failed to add client CA certificate to root store")?;
1633 }
1634 let verifier = rustls::server::WebPkiClientVerifier::builder(Arc::new(roots))
1635 .build()
1636 .context("failed to build client certificate verifier")?;
1637 Ok(Some(verifier))
1638}
1639
1640async fn serve_tls(
1642 listener: tokio::net::TcpListener,
1643 app: Router,
1644 acceptor: tokio_rustls::TlsAcceptor,
1645 server_mode: bool,
1646) -> Result<()> {
1647 use hyper_util::rt::{TokioExecutor, TokioIo};
1648 use hyper_util::server::conn::auto::Builder as ConnBuilder;
1649 use hyper_util::service::TowerToHyperService;
1650 use tower::{Service, ServiceExt};
1651
1652 let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
1653
1654 loop {
1655 tokio::select! {
1656 biased;
1657 _ = tokio::signal::ctrl_c() => {
1658 println!();
1659 if server_mode {
1660 println!("Shutting down OxideSLOC server...");
1661 } else {
1662 println!("Shutting down OxideSLOC local web UI...");
1663 }
1664 println!("Server stopped cleanly.");
1665 return Ok(());
1666 }
1667 result = listener.accept() => {
1668 let (tcp, peer_addr) = result.context("TLS accept failed")?;
1669 let acceptor = acceptor.clone();
1670 let mut factory = make_svc.clone();
1671
1672 tokio::spawn(async move {
1673 let tls = match acceptor.accept(tcp).await {
1674 Ok(s) => s,
1675 Err(e) => {
1676 eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
1677 return;
1678 }
1679 };
1680 let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
1681 Ok(f) => match Service::call(f, peer_addr).await {
1682 Ok(s) => s,
1683 Err(_) => return,
1684 },
1685 Err(_) => return,
1686 };
1687 let io = TokioIo::new(tls);
1688 if let Err(e) = ConnBuilder::new(TokioExecutor::new())
1689 .serve_connection(io, TowerToHyperService::new(svc))
1690 .await
1691 {
1692 eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
1693 }
1694 });
1695 }
1696 }
1697 }
1698}
1699
1700fn build_cors_layer(server_mode: bool) -> CorsLayer {
1703 if server_mode {
1704 let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
1705 .unwrap_or_default()
1706 .split(',')
1707 .filter(|s| !s.is_empty())
1708 .filter_map(|s| s.trim().parse().ok())
1709 .collect();
1710 if allowed.is_empty() {
1711 return CorsLayer::new();
1712 }
1713 CorsLayer::new()
1714 .allow_origin(AllowOrigin::list(allowed))
1715 .allow_methods(AllowMethods::list([
1716 axum::http::Method::GET,
1717 axum::http::Method::POST,
1718 ]))
1719 .allow_headers(AllowHeaders::list([
1720 axum::http::header::AUTHORIZATION,
1721 axum::http::header::CONTENT_TYPE,
1722 ]))
1723 } else {
1724 CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
1725 let s = origin.to_str().unwrap_or("");
1726 s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
1727 }))
1728 }
1729}
1730
1731async fn add_security_headers(
1732 State(state): State<AppState>,
1733 mut req: Request<Body>,
1734 next: Next,
1735) -> Response {
1736 let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
1737 req.extensions_mut().insert(CspNonce(nonce.clone()));
1738 let mut resp = next.run(req).await;
1739 inject_page_fade_into_html(&mut resp, &nonce).await;
1740 let h = resp.headers_mut();
1741 let frame_ancestors = std::env::var("SLOC_FRAME_ANCESTORS")
1749 .ok()
1750 .map(|v| v.trim().to_string())
1751 .filter(|v| !v.is_empty());
1752 if frame_ancestors.is_none() {
1753 h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
1754 }
1755 let frame_ancestors_directive = frame_ancestors.as_deref().unwrap_or("'none'");
1756 h.insert(
1757 "X-Content-Type-Options",
1758 HeaderValue::from_static("nosniff"),
1759 );
1760 h.insert(
1761 "Referrer-Policy",
1762 HeaderValue::from_static("strict-origin-when-cross-origin"),
1763 );
1764 let csp = format!(
1765 "default-src 'self'; \
1766 base-uri 'self'; \
1767 form-action 'self'; \
1768 style-src 'self' 'unsafe-inline'; \
1769 img-src 'self' data: blob:; \
1770 script-src 'self' 'nonce-{nonce}'; \
1771 font-src 'self' data:; \
1772 object-src 'none'; \
1773 frame-ancestors {frame_ancestors_directive}"
1774 );
1775 h.insert(
1776 "Content-Security-Policy",
1777 HeaderValue::from_str(&csp).unwrap_or_else(|_| {
1778 HeaderValue::from_static(
1779 "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
1780 )
1781 }),
1782 );
1783 h.insert(
1784 "X-Permitted-Cross-Domain-Policies",
1785 HeaderValue::from_static("none"),
1786 );
1787 h.insert(
1788 "Permissions-Policy",
1789 HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
1790 );
1791 h.insert(
1792 "Cross-Origin-Opener-Policy",
1793 HeaderValue::from_static("same-origin"),
1794 );
1795 h.insert(
1796 "Cross-Origin-Resource-Policy",
1797 HeaderValue::from_static("same-origin"),
1798 );
1799 h.insert(
1802 "Cross-Origin-Embedder-Policy",
1803 HeaderValue::from_static("require-corp"),
1804 );
1805 if state.tls_enabled {
1806 h.insert(
1807 "Strict-Transport-Security",
1808 HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1809 );
1810 }
1811 resp
1812}
1813
1814async fn csrf_protect(req: Request<Body>, next: Next) -> Response {
1828 use axum::http::Method;
1829
1830 let is_state_changing = matches!(
1831 *req.method(),
1832 Method::POST | Method::PUT | Method::PATCH | Method::DELETE
1833 );
1834 let path = req.uri().path();
1835 let has_token_auth = req.headers().contains_key("X-API-Key")
1836 || req
1837 .headers()
1838 .get(header::AUTHORIZATION)
1839 .and_then(|v| v.to_str().ok())
1840 .is_some_and(|v| v.starts_with("Bearer "));
1841
1842 if !is_state_changing || path.starts_with("/webhooks/") || has_token_auth {
1843 return next.run(req).await;
1844 }
1845
1846 let headers = req.headers();
1847 let header_str = |name: &header::HeaderName| {
1848 headers
1849 .get(name)
1850 .and_then(|v| v.to_str().ok())
1851 .map(str::to_owned)
1852 };
1853 let origin = header_str(&header::ORIGIN);
1854 let referer = header_str(&header::REFERER);
1855 let host = header_str(&header::HOST);
1856
1857 let authority_of = |url: &str| -> Option<String> {
1859 url.split_once("://")
1860 .map(|(_, rest)| rest.split('/').next().unwrap_or(rest).to_owned())
1861 };
1862
1863 let source_authority = origin
1864 .as_deref()
1865 .and_then(authority_of)
1866 .or_else(|| referer.as_deref().and_then(authority_of));
1867
1868 match (source_authority, host) {
1869 (None, _) => next.run(req).await,
1871 (Some(src), Some(h)) if src == h => next.run(req).await,
1872 (Some(src), host) => {
1873 tracing::warn!(
1874 event = "csrf_rejected",
1875 path = %path,
1876 origin = %src,
1877 host = ?host,
1878 "Cross-origin state-changing request rejected (CSRF guard)"
1879 );
1880 (
1881 StatusCode::FORBIDDEN,
1882 "403 Forbidden — cross-origin request rejected\n",
1883 )
1884 .into_response()
1885 }
1886 }
1887}
1888
1889fn page_fade_html(nonce: &str) -> String {
1897 const STYLE: &str = r"<style>
1906@keyframes sloc-page-fade-in{from{opacity:0;}to{opacity:1;}}
1907.page,.site-footer{animation:sloc-page-fade-in .3s ease-out;}
1908body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:0;transition:opacity .16s ease-in;animation:none;}
1909@media (prefers-reduced-motion:reduce){.page,.site-footer{animation:none;}body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:1;transition:none;}}
1910</style>";
1911 const JS: &str = r"(function(){try{if(localStorage.getItem('sloc-dark')==='1'&&document.body)document.body.classList.add('dark-theme');}catch(e){}function leave(e){if(e.defaultPrevented||e.button!==0||e.metaKey||e.ctrlKey||e.shiftKey||e.altKey)return;var a=e.target&&e.target.closest?e.target.closest('a[href]'):null;if(!a)return;if(a.target&&a.target!=='_self')return;if(a.hasAttribute('download'))return;var href=a.getAttribute('href');if(!href||href.charAt(0)==='#')return;if(/^(mailto:|tel:|javascript:)/i.test(href))return;var u;try{u=new URL(a.href,location.href);}catch(_){return;}if(u.origin!==location.origin)return;if(u.pathname===location.pathname&&u.search===location.search)return;var b=document.body;if(!b)return;b.classList.add('sloc-leaving');setTimeout(function(){b.classList.remove('sloc-leaving');},1400);}document.addEventListener('click',leave);window.addEventListener('pageshow',function(){if(document.body)document.body.classList.remove('sloc-leaving');});})();";
1920 format!("{STYLE}<script nonce=\"{nonce}\">{JS}</script>")
1921}
1922
1923fn loading_overlay_block(nonce: &str, aria_label: &str) -> String {
1938 const TPL: &str = r#"<style nonce="__N__">
1939html.sloc-pending body{visibility:hidden;}
1940html.sloc-pending #rpt-loading-overlay{visibility:visible;}
1941#rpt-loading-overlay{position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;overflow:hidden;transition:opacity .45s cubic-bezier(.4,0,.2,1);background:radial-gradient(125% 125% at 50% 0%,#fbf4ec 0%,#f4ebe0 45%,#ecdfd0 100%);}
1942#rpt-loading-overlay.fade-out{opacity:0;pointer-events:none;}
1943body.dark-theme #rpt-loading-overlay{background:radial-gradient(125% 125% at 50% 0%,#241810 0%,#1a120b 45%,#130c06 100%);}
1944body.pdf-mode #rpt-loading-overlay{display:none!important;}
1945.rpt-bg-blob{position:absolute;border-radius:50%;filter:blur(64px);opacity:.5;pointer-events:none;will-change:transform;}
1946.rpt-blob-a{width:48vw;height:48vw;left:-10vw;top:-12vw;background:radial-gradient(circle,#e8932f,transparent 64%);animation:rpt-drift-a 17s ease-in-out infinite;}
1947.rpt-blob-b{width:42vw;height:42vw;right:-8vw;bottom:-10vw;background:radial-gradient(circle,#d3621a,transparent 64%);animation:rpt-drift-b 21s ease-in-out infinite;}
1948@keyframes rpt-drift-a{0%,100%{transform:translate3d(0,0,0) scale(1);}50%{transform:translate3d(9vw,7vw,0) scale(1.18);}}
1949@keyframes rpt-drift-b{0%,100%{transform:translate3d(0,0,0) scale(1.06);}50%{transform:translate3d(-8vw,-6vw,0) scale(.88);}}
1950body.dark-theme .rpt-bg-blob{opacity:.36;}
1951.rpt-load-card{position:relative;z-index:1;display:flex;flex-direction:column;align-items:center;gap:20px;width:380px;max-width:88vw;padding:42px 50px 34px;background:linear-gradient(155deg,rgba(255,255,253,.95),rgba(255,248,240,.9));border:1px solid rgba(196,110,40,.16);border-radius:24px;box-shadow:0 1px 0 rgba(255,255,255,.8) inset,0 22px 64px rgba(120,64,16,.16),0 4px 16px rgba(0,0,0,.06);animation:rpt-card-in .5s cubic-bezier(.22,.68,0,1.12) both;}
1952@keyframes rpt-card-in{from{opacity:0;transform:translateY(14px) scale(.96);}to{opacity:1;transform:none;}}
1953body.dark-theme .rpt-load-card{background:linear-gradient(155deg,rgba(42,24,12,.92),rgba(28,15,6,.95));border-color:rgba(200,120,50,.16);box-shadow:0 1px 0 rgba(255,200,140,.05) inset,0 22px 64px rgba(0,0,0,.5),0 4px 16px rgba(0,0,0,.35);}
1954.rpt-load-logo{width:54px;height:54px;object-fit:contain;filter:drop-shadow(0 6px 16px rgba(90,48,12,.45));}
1955.rpt-spinner-wrap{position:relative;width:84px;height:84px;}
1956.rpt-spinner-track{position:absolute;inset:0;border-radius:50%;border:5px solid rgba(196,92,16,.12);}
1957.rpt-spinner{position:absolute;inset:0;border-radius:50%;background:conic-gradient(from 0deg,rgba(196,92,16,0) 0%,rgba(196,92,16,.18) 35%,#c45c10 100%);will-change:transform;animation:rpt-spin 1s linear infinite;-webkit-mask:radial-gradient(farthest-side,transparent calc(100% - 6px),#fff calc(100% - 5px));mask:radial-gradient(farthest-side,transparent calc(100% - 6px),#fff calc(100% - 5px));}
1958@keyframes rpt-spin{to{transform:rotate(360deg);}}
1959.rpt-spinner-pct{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:16px;font-weight:800;color:#c45c10;font-variant-numeric:tabular-nums;}
1960body.dark-theme .rpt-spinner-track{border-color:rgba(196,92,16,.2);}
1961body.dark-theme .rpt-spinner-pct{color:#e8932f;}
1962.rpt-loading-text{font-size:15px;font-weight:600;letter-spacing:.08em;display:flex;align-items:baseline;gap:2px;}
1963.rpt-load-word{background:linear-gradient(90deg,#9a7a64 0%,#c45c10 45%,#e08a3a 55%,#9a7a64 100%);background-size:220% auto;-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;color:transparent;animation:rpt-text-shimmer 3.2s linear infinite;}
1964@keyframes rpt-text-shimmer{to{background-position:-220% center;}}
1965.rpt-dot{display:inline-block;color:#c45c10;-webkit-text-fill-color:#c45c10;animation:rpt-bounce 1.7s ease-in-out infinite;opacity:0;}
1966.rpt-dot:nth-child(2){animation-delay:.28s;}
1967.rpt-dot:nth-child(3){animation-delay:.56s;}
1968@keyframes rpt-bounce{0%,60%,100%{opacity:0;transform:translateY(0);}30%{opacity:1;transform:translateY(-5px);}}
1969.rpt-status{font-size:12.5px;font-weight:600;letter-spacing:.02em;color:var(--muted,#8a7060);min-height:16px;text-align:center;}
1970.rpt-progress{width:100%;height:6px;border-radius:99px;background:rgba(196,92,16,.12);overflow:hidden;}
1971.rpt-progress-bar{height:100%;width:100%;transform:scaleX(0);transform-origin:left center;border-radius:99px;background:linear-gradient(90deg,#e8932f,#c45c10);transition:transform .25s cubic-bezier(.4,0,.2,1);will-change:transform;}
1972body.dark-theme .rpt-progress{background:rgba(196,92,16,.2);}
1973@media (prefers-reduced-motion:reduce){ #rpt-loading-overlay .rpt-bg-blob,#rpt-loading-overlay .rpt-spinner,#rpt-loading-overlay .rpt-load-word,#rpt-loading-overlay .rpt-dot{animation:none!important;}}
1974</style>
1975<noscript><style nonce="__N__">html.sloc-pending body{visibility:visible!important;}#rpt-loading-overlay{display:none!important;}</style></noscript>
1976<script nonce="__N__">document.documentElement.classList.add('sloc-pending');try{if(localStorage.getItem('sloc-dark')==='1'||localStorage.getItem('oxide-sloc-theme')==='dark')document.body.classList.add('dark-theme');}catch(e){}</script>
1977<div id="rpt-loading-overlay" aria-live="polite" aria-label="__LABEL__">
1978 <div class="rpt-bg-blob rpt-blob-a" aria-hidden="true"></div>
1979 <div class="rpt-bg-blob rpt-blob-b" aria-hidden="true"></div>
1980 <div class="rpt-load-card">
1981 <img src="/images/logo/small-logo.png" alt="oxide-sloc" class="rpt-load-logo" />
1982 <div class="rpt-spinner-wrap">
1983 <div class="rpt-spinner-track"></div>
1984 <div class="rpt-spinner"></div>
1985 <div class="rpt-spinner-pct" id="rpt-pct">0%</div>
1986 </div>
1987 <div class="rpt-loading-text"><span class="rpt-load-word">Loading comparison</span><span class="rpt-dot">.</span><span class="rpt-dot">.</span><span class="rpt-dot">.</span></div>
1988 <div class="rpt-status" id="rpt-status">__LABEL__</div>
1989 <div class="rpt-progress"><div class="rpt-progress-bar" id="rpt-progress-bar"></div></div>
1990 </div>
1991</div>
1992<script nonce="__N__">
1993(function(){
1994 var ov=document.getElementById('rpt-loading-overlay');
1995 var root=document.documentElement;
1996 function reveal(){root.classList.remove('sloc-pending');}
1997 if(!ov){reveal();return;}
1998 var bar=document.getElementById('rpt-progress-bar'),pct=document.getElementById('rpt-pct'),statusEl=document.getElementById('rpt-status');
1999 var msgs=['__LABEL__','Reading baseline scan','Reading current scan','Computing line deltas','Building file matrix','Rendering charts'];
2000 var mi=0,prog=0,done=false,start=Date.now();
2001 // MIN: minimum time the overlay stays up. SETTLE: extra buffer after the page
2002 // reports ready so the final chart paint completes. CHART_CAP: stop waiting on
2003 // charts after this. HARD_CAP: absolute backstop so the overlay can never stick.
2004 var MIN=1200,SETTLE=750,CHART_CAP=12000,HARD_CAP=25000;
2005 function setProg(p){prog=p;if(bar)bar.style.transform='scaleX('+(p/100).toFixed(3)+')';if(pct)pct.textContent=Math.round(p)+'%';}
2006 function nextMsg(){if(statusEl)statusEl.textContent=msgs[mi%msgs.length];mi++;}
2007 setProg(8);
2008 var msgTimer=setInterval(nextMsg,700);
2009 var progTimer=setInterval(function(){var cap=99;if(prog<cap){var step=(cap-prog)*0.05+0.4;setProg(Math.min(cap,prog+step));}},90);
2010 // These pages draw charts into known SVG containers that start empty and are
2011 // filled by JS once layout is available (some only after a ResizeObserver pass
2012 // post-`load`). Treat the page as ready only once every chart container present
2013 // actually has rendered content, so the overlay never lifts on a half-drawn page.
2014 function chartsRendered(){
2015 var sel=['#cmp-tl-svg','#mc-chart'];
2016 for(var i=0;i<sel.length;i++){var el=document.querySelector(sel[i]);if(el&&!el.firstChild)return false;}
2017 return true;
2018 }
2019 function finish(){
2020 if(done)return;done=true;
2021 clearInterval(msgTimer);clearInterval(progTimer);setProg(100);if(statusEl)statusEl.textContent='Done';
2022 // Reveal the fully-rendered page under the still-opaque overlay, let it paint
2023 // for two frames, THEN fade the overlay — so no half-rendered state is shown.
2024 reveal();
2025 requestAnimationFrame(function(){requestAnimationFrame(function(){
2026 setTimeout(function(){ov.classList.add('fade-out');setTimeout(function(){if(ov.parentNode)ov.parentNode.removeChild(ov);},480);},80);
2027 });});
2028 }
2029 // Wait for `load` (resources + first layout), then poll until the charts have
2030 // actually rendered (or the chart cap), then hold for MIN + SETTLE before fading.
2031 function afterLoad(){
2032 var loadAt=Date.now();
2033 (function poll(){
2034 if(done)return;
2035 if(chartsRendered()||Date.now()-loadAt>=CHART_CAP){
2036 setTimeout(finish,Math.max(MIN-(Date.now()-start),0)+SETTLE);
2037 return;
2038 }
2039 requestAnimationFrame(poll);
2040 })();
2041 }
2042 if(document.readyState==='complete')afterLoad();else window.addEventListener('load',afterLoad);
2043 // Absolute safety net: never let the gate/overlay get stuck.
2044 setTimeout(function(){if(!done)finish();},HARD_CAP);
2045})();
2046</script>"#;
2047 TPL.replace("__N__", nonce).replace("__LABEL__", aria_label)
2048}
2049
2050fn sloc_toast_assets(nonce: &str) -> String {
2065 const TPL: &str = r#"<style nonce="__N__">
2066#sloc-toast-wrap{position:fixed;right:18px;top:18px;z-index:11000;display:flex;flex-direction:column;gap:10px;max-width:min(380px,calc(100vw - 36px));pointer-events:none;}
2067.sloc-toast{pointer-events:auto;display:flex;align-items:flex-start;gap:10px;padding:12px 14px;border-radius:12px;background:#fcfaf7;color:#2f241c;border:1px solid #dfcfbf;box-shadow:0 12px 32px rgba(77,44,20,0.22);font-family:Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:13px;font-weight:600;line-height:1.35;opacity:0;transform:translateY(12px) scale(.96);transition:opacity .26s ease,transform .26s cubic-bezier(.22,.68,0,1.12);}
2068.sloc-toast.sloc-toast-in{opacity:1;transform:none;}
2069.sloc-toast.sloc-toast-out{opacity:0;transform:translateY(8px) scale(.97);}
2070.sloc-toast-ico{flex:0 0 auto;width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:900;color:#fff;font-style:normal;}
2071.sloc-toast-success .sloc-toast-ico{background:#2a6846;}
2072.sloc-toast-error .sloc-toast-ico{background:#b23030;}
2073.sloc-toast-info .sloc-toast-ico{background:#c45c10;}
2074.sloc-toast-success{border-color:#bfe0cc;}
2075.sloc-toast-error{border-color:#e6b3b3;}
2076.sloc-toast-msg{flex:1 1 auto;padding-top:1px;word-break:break-word;}
2077.sloc-toast-spin{flex:0 0 auto;width:18px;height:18px;border-radius:50%;border:2.5px solid rgba(196,92,16,.25);border-top-color:#c45c10;animation:sloc-toast-spin .7s linear infinite;}
2078@keyframes sloc-toast-spin{to{transform:rotate(360deg);}}
2079.sloc-toast-x{flex:0 0 auto;background:none;border:none;color:inherit;opacity:.5;cursor:pointer;font-size:16px;line-height:1;padding:0 2px;margin:-1px -2px 0 2px;}
2080.sloc-toast-x:hover{opacity:1;}
2081body.dark-theme .sloc-toast{background:#241a12;color:#f0e6dc;border-color:#3a2c20;box-shadow:0 12px 32px rgba(0,0,0,.5);}
2082body.dark-theme .sloc-toast-success{border-color:#2f5a44;}
2083body.dark-theme .sloc-toast-error{border-color:#6e3434;}
2084body.dark-theme .sloc-toast-spin{border-color:rgba(232,147,47,.25);border-top-color:#e8932f;}
2085@media (prefers-reduced-motion:reduce){.sloc-toast{transition:opacity .2s ease;transform:none!important;}}
2086</style>
2087<script nonce="__N__">
2088(function(){
2089 if(window.slocToast)return;
2090 function wrap(){
2091 var w=document.getElementById('sloc-toast-wrap');
2092 if(!w){w=document.createElement('div');w.id='sloc-toast-wrap';w.setAttribute('aria-live','polite');w.setAttribute('aria-atomic','false');(document.body||document.documentElement).appendChild(w);}
2093 return w;
2094 }
2095 window.slocToast=function(msg,opts){
2096 opts=opts||{};
2097 var type=opts.type||'info';
2098 var loading=type==='loading';
2099 var t=document.createElement('div');
2100 t.className='sloc-toast sloc-toast-'+(loading?'info':type);
2101 t.setAttribute('role',type==='error'?'alert':'status');
2102 var ico=loading
2103 ? '<span class="sloc-toast-spin" aria-hidden="true"></span>'
2104 : '<span class="sloc-toast-ico" aria-hidden="true">'+(type==='success'?'✓':type==='error'?'✕':'i')+'</span>';
2105 t.innerHTML=ico+'<span class="sloc-toast-msg"></span><button type="button" class="sloc-toast-x" aria-label="Dismiss">×</button>';
2106 t.querySelector('.sloc-toast-msg').textContent=String(msg);
2107 wrap().appendChild(t);
2108 requestAnimationFrame(function(){t.classList.add('sloc-toast-in');});
2109 var gone=false,timer=null;
2110 function close(){
2111 if(gone)return;gone=true;if(timer)clearTimeout(timer);
2112 t.classList.remove('sloc-toast-in');t.classList.add('sloc-toast-out');
2113 setTimeout(function(){if(t.parentNode)t.parentNode.removeChild(t);},300);
2114 }
2115 t.querySelector('.sloc-toast-x').addEventListener('click',close);
2116 var ttl=opts.duration!=null?opts.duration:(type==='error'?7000:loading?0:4500);
2117 if(ttl>0)timer=setTimeout(close,ttl);
2118 return {dismiss:close,el:t};
2119 };
2120 window.slocExportPdf=function(o){
2121 o=o||{};
2122 var btn=o.button||null,orig=btn?btn.innerHTML:'',fname=o.filename||'report.pdf';
2123 if(btn&&btn.disabled)return;
2124 if(btn){btn.disabled=true;btn.style.opacity='0.55';btn.style.cursor='not-allowed';btn.textContent='Generating PDF…';}
2125 var load=window.slocToast('Generating PDF… this can take a few seconds.',{type:'loading'});
2126 return fetch('/export/pdf',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({html:o.html,filename:fname})})
2127 .then(function(r){if(!r.ok)throw new Error('server returned '+r.status);return r.blob();})
2128 .then(function(blob){
2129 var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;
2130 document.body.appendChild(a);a.click();document.body.removeChild(a);
2131 setTimeout(function(){URL.revokeObjectURL(a.href);},400);
2132 load.dismiss();
2133 window.slocToast('PDF exported — '+fname+' saved to your local disk.',{type:'success'});
2134 })
2135 .catch(function(e){
2136 load.dismiss();
2137 window.slocToast('PDF export failed: '+e.message+'. A Chromium-based browser (Chrome/Edge/Brave) must be installed on the server.',{type:'error'});
2138 })
2139 .finally(function(){if(btn){btn.disabled=false;btn.style.opacity='';btn.style.cursor='';btn.innerHTML=orig;}});
2140 };
2141})();
2142</script>"#;
2143 TPL.replace("__N__", nonce)
2144}
2145
2146async fn inject_page_fade_into_html(resp: &mut Response, nonce: &str) {
2151 let is_html = resp
2152 .headers()
2153 .get(header::CONTENT_TYPE)
2154 .and_then(|v| v.to_str().ok())
2155 .is_some_and(|v| v.starts_with("text/html"));
2156 if !is_html {
2157 return;
2158 }
2159 let body = std::mem::replace(resp.body_mut(), Body::empty());
2160 let Ok(bytes) = axum::body::to_bytes(body, usize::MAX).await else {
2161 return;
2162 };
2163 let html = match String::from_utf8(bytes.to_vec()) {
2164 Ok(s) => s,
2165 Err(e) => {
2166 *resp.body_mut() = Body::from(e.into_bytes());
2167 return;
2168 }
2169 };
2170 if html.contains("id=\"rpt-loading-overlay\"") {
2171 *resp.body_mut() = Body::from(html);
2172 return;
2173 }
2174 let insert_at = html
2178 .find("<body")
2179 .and_then(|bi| html[bi..].find('>').map(|g| bi + g + 1))
2180 .or_else(|| {
2181 let lower = html.to_ascii_lowercase();
2182 lower
2183 .find("<body")
2184 .and_then(|bi| lower[bi..].find('>').map(|g| bi + g + 1))
2185 });
2186 let new_html = match insert_at {
2187 Some(at) => {
2188 let mut out = String::with_capacity(html.len() + 1024);
2189 out.push_str(&html[..at]);
2190 out.push_str(&page_fade_html(nonce));
2191 out.push_str(&html[at..]);
2192 out
2193 }
2194 None => html,
2195 };
2196 resp.headers_mut().remove(header::CONTENT_LENGTH);
2197 *resp.body_mut() = Body::from(new_html);
2198}
2199
2200async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
2201 let peer_ip = req
2202 .extensions()
2203 .get::<axum::extract::ConnectInfo<SocketAddr>>()
2204 .map(|c| c.0.ip());
2205
2206 let ip = peer_ip
2210 .and_then(|peer| {
2211 if state.trust_proxy && state.trusted_proxy_ips.contains(&peer) {
2212 req.headers()
2213 .get("X-Forwarded-For")
2214 .and_then(|v| v.to_str().ok())
2215 .and_then(|s| s.split(',').next())
2216 .and_then(|s| s.trim().parse::<IpAddr>().ok())
2217 } else {
2218 None
2219 }
2220 })
2221 .or(peer_ip)
2222 .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
2223
2224 if !state.rate_limiter.is_allowed(ip) {
2225 tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
2226 path = %req.uri().path(), "Rate limit exceeded");
2227 return (
2228 StatusCode::TOO_MANY_REQUESTS,
2229 [(header::RETRY_AFTER, "60")],
2230 "429 Too Many Requests\n",
2231 )
2232 .into_response();
2233 }
2234 next.run(req).await
2235}
2236
2237async fn splash(
2238 State(state): State<AppState>,
2239 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2240) -> impl IntoResponse {
2241 let lan_ip = if state.server_mode {
2242 primary_lan_ip()
2243 } else {
2244 None
2245 };
2246 let port = state
2247 .base_config
2248 .web
2249 .bind_address
2250 .rsplit(':')
2251 .next()
2252 .and_then(|p| p.parse::<u16>().ok())
2253 .unwrap_or(4317);
2254 let has_api_key = !state.api_keys.is_empty();
2255 let template = SplashTemplate {
2256 csp_nonce,
2257 server_mode: state.server_mode,
2258 lan_ip,
2259 port,
2260 version: env!("CARGO_PKG_VERSION"),
2261 has_api_key,
2262 };
2263 Html(
2264 template
2265 .render()
2266 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2267 )
2268}
2269
2270async fn index(
2271 State(state): State<AppState>,
2272 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2273 Query(query): Query<IndexQuery>,
2274) -> impl IntoResponse {
2275 let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
2276 let policy = query
2277 .mixed_line_policy
2278 .unwrap_or_else(|| "code_only".to_string());
2279 let behavior = query
2280 .binary_file_behavior
2281 .unwrap_or_else(|| "skip".to_string());
2282 let cfg = ScanConfig {
2283 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
2284 path: query.path.unwrap_or_default(),
2285 include_globs: query.include_globs.unwrap_or_default(),
2286 exclude_globs: query.exclude_globs.unwrap_or_default(),
2287 submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
2288 mixed_line_policy: policy,
2289 python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
2290 != Some("off"),
2291 generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
2292 minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
2293 vendor_directory_detection: query.vendor_directory_detection.as_deref()
2294 != Some("disabled"),
2295 include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
2296 binary_file_behavior: behavior,
2297 output_dir: query.output_dir.unwrap_or_default(),
2298 report_title: query.report_title.unwrap_or_default(),
2299 continuation_line_policy: query
2300 .continuation_line_policy
2301 .unwrap_or_else(default_each_physical_line),
2302 blank_in_block_comment_policy: query
2303 .blank_in_block_comment_policy
2304 .unwrap_or_else(default_count_as_comment),
2305 count_compiler_directives: query.count_compiler_directives.as_deref()
2306 != Some("disabled"),
2307 style_analysis_enabled: query.style_analysis_enabled.as_deref() != Some("disabled"),
2308 style_col_threshold: query
2309 .style_col_threshold
2310 .as_deref()
2311 .and_then(|s| s.parse().ok())
2312 .unwrap_or(80),
2313 style_score_threshold: query
2314 .style_score_threshold
2315 .as_deref()
2316 .and_then(|s| s.parse().ok())
2317 .unwrap_or(0),
2318 style_lang_scope: query.style_lang_scope.unwrap_or_else(default_all_scope),
2319 coverage_file: query.coverage_file.unwrap_or_default(),
2320 cocomo_mode: query.cocomo_mode.unwrap_or_else(default_organic),
2321 complexity_alert: query
2322 .complexity_alert
2323 .as_deref()
2324 .and_then(|s| s.parse().ok())
2325 .unwrap_or(0),
2326 exclude_duplicates: query.exclude_duplicates.as_deref() == Some("enabled"),
2327 activity_window: query
2328 .activity_window
2329 .as_deref()
2330 .and_then(|s| s.parse().ok())
2331 .unwrap_or(90),
2332 };
2333 serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
2334 } else {
2335 "{}".to_string()
2336 };
2337
2338 let git_repo = query.git_repo.unwrap_or_default();
2339 let git_ref = query.git_ref.unwrap_or_default();
2340
2341 let git_label = make_git_label(&git_repo, &git_ref);
2342 let git_output_dir = if git_label.is_empty() {
2343 String::new()
2344 } else {
2345 desktop_dir().join(&git_label).display().to_string()
2346 };
2347 let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
2348 let git_output_dir_json =
2349 serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
2350
2351 let template = IndexTemplate {
2352 version: env!("CARGO_PKG_VERSION"),
2353 prefill_json,
2354 csp_nonce,
2355 git_repo,
2356 git_ref,
2357 git_label_json,
2358 git_output_dir_json,
2359 server_mode: state.server_mode,
2360 };
2361
2362 Html(
2363 template
2364 .render()
2365 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2366 )
2367}
2368
2369async fn scan_setup_handler(
2370 State(state): State<AppState>,
2371 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2372) -> impl IntoResponse {
2373 let recent_scans_json = {
2374 let arr: Vec<serde_json::Value> = {
2375 let reg = state.registry.lock().await;
2376 reg.entries
2377 .iter()
2378 .rev()
2379 .take(6)
2380 .map(|e| {
2381 let run_dir = e
2382 .html_path
2383 .as_ref()
2384 .or(e.json_path.as_ref())
2385 .and_then(|p| p.parent().map(PathBuf::from));
2386 let config_val: Option<serde_json::Value> = run_dir
2387 .and_then(|d| find_scan_config_in_dir(&d))
2388 .and_then(|p| fs::read_to_string(&p).ok())
2389 .and_then(|s| serde_json::from_str(&s).ok());
2390 serde_json::json!({
2391 "project_label": e.project_label,
2392 "timestamp": fmt_la_time(e.timestamp_utc),
2393 "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
2394 "config": config_val,
2395 })
2396 })
2397 .collect()
2398 };
2399 serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
2400 };
2401
2402 let template = ScanSetupTemplate {
2403 version: env!("CARGO_PKG_VERSION"),
2404 recent_scans_json,
2405 csp_nonce,
2406 };
2407 Html(
2408 template
2409 .render()
2410 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2411 )
2412}
2413
2414async fn healthz() -> &'static str {
2415 "ok"
2416}
2417
2418async fn api_version_handler() -> impl IntoResponse {
2419 axum::Json(serde_json::json!({
2420 "name": "oxide-sloc",
2421 "version": env!("CARGO_PKG_VERSION"),
2422 }))
2423}
2424
2425fn prom_runs_total() -> &'static prometheus::IntCounter {
2428 static COUNTER: OnceLock<prometheus::IntCounter> = OnceLock::new();
2429 COUNTER.get_or_init(|| {
2430 prometheus::register_int_counter!(
2431 "oxide_sloc_runs_total",
2432 "Total number of completed analysis runs"
2433 )
2434 .expect("failed to register oxide_sloc_runs_total counter")
2435 })
2436}
2437
2438async fn metrics_handler() -> impl IntoResponse {
2439 use prometheus::Encoder as _;
2440 let mut buf = Vec::new();
2441 let encoder = prometheus::TextEncoder::new();
2442 let _ = encoder.encode(&prometheus::gather(), &mut buf);
2443 (
2444 [(
2445 axum::http::header::CONTENT_TYPE,
2446 "text/plain; version=0.0.4; charset=utf-8",
2447 )],
2448 buf,
2449 )
2450}
2451
2452static OPENAPI_YAML: &str = include_str!("../assets/openapi.yaml");
2453
2454async fn openapi_yaml_handler() -> impl IntoResponse {
2455 (
2456 [(axum::http::header::CONTENT_TYPE, "application/yaml")],
2457 OPENAPI_YAML,
2458 )
2459}
2460
2461static LLMS_TXT: &str = include_str!("../assets/ai/llms.txt");
2462static LLMS_FULL_TXT: &str = include_str!("../assets/ai/llms-full.txt");
2463
2464async fn llms_txt_handler() -> impl IntoResponse {
2465 (
2466 [
2467 (
2468 axum::http::header::CONTENT_TYPE,
2469 "text/plain; charset=utf-8",
2470 ),
2471 (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
2472 ],
2473 LLMS_TXT,
2474 )
2475}
2476
2477async fn llms_full_txt_handler() -> impl IntoResponse {
2478 (
2479 [
2480 (
2481 axum::http::header::CONTENT_TYPE,
2482 "text/plain; charset=utf-8",
2483 ),
2484 (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
2485 ],
2486 LLMS_FULL_TXT,
2487 )
2488}
2489
2490async fn api_docs_handler(
2491 State(state): State<AppState>,
2492 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2493) -> impl IntoResponse {
2494 let has_api_key = !state.api_keys.is_empty();
2495 Html(
2496 ApiDocsTemplate {
2497 has_api_key,
2498 csp_nonce,
2499 version: env!("CARGO_PKG_VERSION"),
2500 }
2501 .render()
2502 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
2503 )
2504}
2505
2506async fn chart_js_handler() -> impl IntoResponse {
2507 (
2508 [
2509 (
2510 header::CONTENT_TYPE,
2511 "application/javascript; charset=utf-8",
2512 ),
2513 (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
2514 ],
2515 CHART_JS,
2516 )
2517}
2518
2519async fn report_chart_js_handler() -> impl IntoResponse {
2520 (
2521 [
2522 (
2523 header::CONTENT_TYPE,
2524 "application/javascript; charset=utf-8",
2525 ),
2526 (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
2527 ],
2528 REPORT_CHART_JS,
2529 )
2530}
2531
2532#[derive(Debug, Deserialize)]
2533struct AnalyzeForm {
2534 path: String,
2535 git_repo: Option<String>,
2536 git_ref: Option<String>,
2537 mixed_line_policy: Option<MixedLinePolicy>,
2538 python_docstrings_as_comments: Option<String>,
2539 generated_file_detection: Option<String>,
2540 minified_file_detection: Option<String>,
2541 vendor_directory_detection: Option<String>,
2542 include_lockfiles: Option<String>,
2543 binary_file_behavior: Option<BinaryFileBehavior>,
2544 output_dir: Option<String>,
2545 report_title: Option<String>,
2546 report_header_footer: Option<String>,
2547 include_globs: Option<String>,
2548 exclude_globs: Option<String>,
2549 submodule_breakdown: Option<String>,
2550 coverage_file: Option<String>,
2551 continuation_line_policy: Option<ContinuationLinePolicy>,
2552 blank_in_block_comment_policy: Option<BlankInBlockCommentPolicy>,
2553 count_compiler_directives: Option<String>,
2554 style_col_threshold: Option<String>,
2555 style_analysis_enabled: Option<String>,
2556 style_score_threshold: Option<String>,
2557 style_lang_scope: Option<String>,
2558 cocomo_mode: Option<String>,
2560 complexity_alert: Option<String>,
2562 exclude_duplicates: Option<String>,
2564 activity_window: Option<String>,
2566}
2567
2568#[allow(clippy::struct_excessive_bools)]
2569#[derive(Debug, Serialize, Deserialize, Clone)]
2570struct ScanConfig {
2571 oxide_sloc_version: String,
2572 path: String,
2573 include_globs: String,
2574 exclude_globs: String,
2575 submodule_breakdown: bool,
2576 mixed_line_policy: String,
2577 python_docstrings_as_comments: bool,
2578 generated_file_detection: bool,
2579 minified_file_detection: bool,
2580 vendor_directory_detection: bool,
2581 include_lockfiles: bool,
2582 binary_file_behavior: String,
2583 output_dir: String,
2584 report_title: String,
2585 #[serde(default = "default_each_physical_line")]
2587 continuation_line_policy: String,
2588 #[serde(default = "default_count_as_comment")]
2589 blank_in_block_comment_policy: String,
2590 #[serde(default = "default_true_bool")]
2591 count_compiler_directives: bool,
2592 #[serde(default = "default_true_bool")]
2593 style_analysis_enabled: bool,
2594 #[serde(default = "default_style_col_threshold")]
2595 style_col_threshold: u16,
2596 #[serde(default)]
2597 style_score_threshold: u8,
2598 #[serde(default = "default_all_scope")]
2599 style_lang_scope: String,
2600 #[serde(default)]
2601 coverage_file: String,
2602 #[serde(default = "default_organic")]
2603 cocomo_mode: String,
2604 #[serde(default)]
2605 complexity_alert: u32,
2606 #[serde(default)]
2607 exclude_duplicates: bool,
2608 #[serde(default = "default_activity_window")]
2610 activity_window: u32,
2611}
2612
2613const fn default_activity_window() -> u32 {
2614 90
2615}
2616
2617fn default_each_physical_line() -> String {
2618 "each_physical_line".to_string()
2619}
2620fn default_count_as_comment() -> String {
2621 "count_as_comment".to_string()
2622}
2623const fn default_true_bool() -> bool {
2624 true
2625}
2626const fn default_style_col_threshold() -> u16 {
2627 80
2628}
2629fn default_all_scope() -> String {
2630 "all".to_string()
2631}
2632fn default_organic() -> String {
2633 "organic".to_string()
2634}
2635
2636#[derive(Debug, Deserialize, Default)]
2637struct IndexQuery {
2638 path: Option<String>,
2639 include_globs: Option<String>,
2640 exclude_globs: Option<String>,
2641 submodule_breakdown: Option<String>,
2642 mixed_line_policy: Option<String>,
2643 python_docstrings_as_comments: Option<String>,
2644 generated_file_detection: Option<String>,
2645 minified_file_detection: Option<String>,
2646 vendor_directory_detection: Option<String>,
2647 include_lockfiles: Option<String>,
2648 binary_file_behavior: Option<String>,
2649 output_dir: Option<String>,
2650 report_title: Option<String>,
2651 prefilled: Option<String>,
2652 git_repo: Option<String>,
2653 git_ref: Option<String>,
2654 continuation_line_policy: Option<String>,
2656 blank_in_block_comment_policy: Option<String>,
2657 count_compiler_directives: Option<String>,
2658 style_analysis_enabled: Option<String>,
2659 style_col_threshold: Option<String>,
2660 style_score_threshold: Option<String>,
2661 style_lang_scope: Option<String>,
2662 coverage_file: Option<String>,
2663 cocomo_mode: Option<String>,
2664 complexity_alert: Option<String>,
2665 exclude_duplicates: Option<String>,
2666 activity_window: Option<String>,
2667}
2668
2669#[derive(Debug, Deserialize)]
2670struct PreviewQuery {
2671 path: Option<String>,
2672 include_globs: Option<String>,
2673 exclude_globs: Option<String>,
2674}
2675
2676#[cfg(feature = "native-dialog")]
2677#[derive(Debug, Deserialize)]
2678struct PickDirectoryQuery {
2679 kind: Option<String>,
2680 current: Option<String>,
2681}
2682
2683#[cfg(not(feature = "native-dialog"))]
2684#[derive(Debug, Deserialize)]
2685struct PickDirectoryQuery {}
2686
2687#[derive(Debug, Deserialize, Default)]
2688struct ArtifactQuery {
2689 download: Option<String>,
2690}
2691
2692#[cfg(feature = "native-dialog")]
2693#[derive(Debug, Serialize)]
2694struct PickDirectoryResponse {
2695 selected_path: Option<String>,
2696 cancelled: bool,
2697}
2698
2699#[cfg(feature = "native-dialog")]
2700async fn pick_directory_handler(
2701 State(state): State<AppState>,
2702 Query(query): Query<PickDirectoryQuery>,
2703) -> Response {
2704 if state.server_mode {
2705 return StatusCode::NOT_FOUND.into_response();
2706 }
2707 if std::env::var("SLOC_HEADLESS").is_ok() {
2709 return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
2710 .into_response();
2711 }
2712
2713 let is_coverage = query.kind.as_deref() == Some("coverage");
2714 let title = match query.kind.as_deref() {
2715 Some("output") => "Select output directory",
2716 Some("reports") => "Select folder containing saved reports",
2717 Some("coverage") => "Select LCOV coverage file",
2718 _ => "Select project directory",
2719 }
2720 .to_owned();
2721 let current = query.current.clone();
2722
2723 let picked = tokio::task::spawn_blocking(move || {
2724 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2727 let fg_tid = win_dialog_focus::attach_to_foreground();
2728 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2729 win_dialog_focus::flash_dialog_when_ready(title.clone());
2730
2731 let mut dialog = rfd::FileDialog::new().set_title(&title);
2732 if let Some(current) = current.as_deref() {
2733 let resolved = resolve_input_path(current);
2734 let seed = if resolved.is_dir() {
2735 Some(resolved)
2736 } else {
2737 resolved.parent().map(Path::to_path_buf)
2738 };
2739 if let Some(seed_dir) = seed.filter(|p| p.exists()) {
2740 dialog = dialog.set_directory(seed_dir);
2741 }
2742 }
2743 let result = if is_coverage {
2744 dialog
2745 .add_filter(
2746 "Coverage files (LCOV, Cobertura/JaCoCo XML, coverage.py/Istanbul JSON)",
2747 &["info", "lcov", "xml", "json"],
2748 )
2749 .pick_file()
2750 } else {
2751 dialog.pick_folder()
2752 };
2753
2754 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2755 win_dialog_focus::detach_from_foreground(fg_tid);
2756
2757 result
2758 })
2759 .await
2760 .unwrap_or(None);
2761
2762 Json(PickDirectoryResponse {
2763 selected_path: picked.as_ref().map(|p| display_path(p)),
2764 cancelled: picked.is_none(),
2765 })
2766 .into_response()
2767}
2768
2769#[cfg(not(feature = "native-dialog"))]
2770async fn pick_directory_handler(
2771 State(_state): State<AppState>,
2772 Query(_query): Query<PickDirectoryQuery>,
2773) -> Response {
2774 Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
2775}
2776
2777#[cfg(feature = "native-dialog")]
2778async fn pick_file_handler(State(state): State<AppState>) -> Response {
2779 if state.server_mode {
2780 return StatusCode::NOT_FOUND.into_response();
2781 }
2782 if std::env::var("SLOC_HEADLESS").is_ok() {
2783 return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
2784 .into_response();
2785 }
2786 let picked = tokio::task::spawn_blocking(|| {
2787 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2788 let fg_tid = win_dialog_focus::attach_to_foreground();
2789 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2790 win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
2791
2792 let result = rfd::FileDialog::new()
2793 .set_title("Select HTML report")
2794 .add_filter("HTML report", &["html"])
2795 .pick_file();
2796
2797 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2798 win_dialog_focus::detach_from_foreground(fg_tid);
2799
2800 result
2801 })
2802 .await
2803 .unwrap_or(None);
2804 Json(PickDirectoryResponse {
2805 selected_path: picked.as_ref().map(|p| display_path(p)),
2806 cancelled: picked.is_none(),
2807 })
2808 .into_response()
2809}
2810
2811#[cfg(not(feature = "native-dialog"))]
2812async fn pick_file_handler(State(_state): State<AppState>) -> Response {
2813 Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
2814}
2815
2816fn is_upload_tmp_path(path: &Path) -> bool {
2821 let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
2822 path.starts_with(&upload_root)
2823}
2824
2825fn is_sample_path(path: &Path) -> bool {
2828 let root = workspace_root();
2829 path.starts_with(root.join("tests").join("fixtures")) || path.starts_with(root.join("samples"))
2830}
2831
2832fn upload_base_dir() -> PathBuf {
2834 std::env::temp_dir().join("oxide-sloc-uploads")
2835}
2836
2837fn upload_staging_path(id: &str) -> PathBuf {
2839 upload_base_dir().join(id)
2840}
2841
2842#[allow(clippy::result_large_err)] fn validate_upload_dir_request(body: &UploadDirRequest) -> Result<(), Response> {
2846 const MAX_FILES: usize = 50_000;
2847 if body.files.is_empty() {
2848 return Err((
2849 StatusCode::BAD_REQUEST,
2850 Json(serde_json::json!({"error": "No files received"})),
2851 )
2852 .into_response());
2853 }
2854 if body.files.len() > MAX_FILES {
2855 return Err((
2856 StatusCode::PAYLOAD_TOO_LARGE,
2857 Json(serde_json::json!({"error": "Too many files (limit 50 000)"})),
2858 )
2859 .into_response());
2860 }
2861 Ok(())
2862}
2863
2864fn resolve_or_create_staging(id: Option<&str>) -> (String, PathBuf) {
2867 match id {
2868 Some(id)
2869 if !id.is_empty()
2870 && id.len() <= 36
2871 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') =>
2872 {
2873 (id.to_string(), upload_staging_path(id))
2874 }
2875 _ => {
2876 let new_id = uuid::Uuid::new_v4().to_string();
2877 let staging = upload_staging_path(&new_id);
2878 (new_id, staging)
2879 }
2880 }
2881}
2882
2883#[allow(clippy::result_large_err)]
2888async fn stage_decoded_entry(
2889 entry: &UploadedFile,
2890 staging: &Path,
2891 total_bytes: &mut usize,
2892 project_root: &mut Option<PathBuf>,
2893) -> Result<(), Response> {
2894 const MAX_TOTAL_BYTES: usize = 500 * 1024 * 1024;
2895
2896 let Ok(data) = base64::Engine::decode(
2897 &base64::engine::general_purpose::STANDARD,
2898 entry.content.as_bytes(),
2899 ) else {
2900 return Ok(());
2901 };
2902
2903 *total_bytes += data.len();
2904 if *total_bytes > MAX_TOTAL_BYTES {
2905 return Err((
2906 StatusCode::PAYLOAD_TOO_LARGE,
2907 Json(serde_json::json!({"error": "Upload exceeds the 500 MB limit"})),
2908 )
2909 .into_response());
2910 }
2911
2912 let rel = std::path::Path::new(&entry.path);
2913 if project_root.is_none() {
2914 if let Some(first) = rel.components().next() {
2915 *project_root = Some(staging.join(first.as_os_str()));
2916 }
2917 }
2918
2919 let dest = staging.join(rel);
2920 if let Some(parent) = dest.parent() {
2921 if tokio::fs::create_dir_all(parent).await.is_err() {
2922 return Err((
2923 StatusCode::INTERNAL_SERVER_ERROR,
2924 Json(serde_json::json!({"error": "Failed to create directory structure"})),
2925 )
2926 .into_response());
2927 }
2928 }
2929
2930 if tokio::fs::write(&dest, &data).await.is_err() {
2931 return Err((
2932 StatusCode::INTERNAL_SERVER_ERROR,
2933 Json(serde_json::json!({"error": "Failed to write uploaded file"})),
2934 )
2935 .into_response());
2936 }
2937
2938 Ok(())
2939}
2940
2941async fn write_upload_files(
2945 files: &[UploadedFile],
2946 staging: &Path,
2947 upload_id: &str,
2948) -> Result<(usize, Option<PathBuf>), Response> {
2949 let mut total_bytes: usize = 0;
2950 let mut project_root: Option<PathBuf> = None;
2951
2952 for entry in files {
2953 let rel = std::path::Path::new(&entry.path);
2954 if rel
2955 .components()
2956 .any(|c| matches!(c, std::path::Component::ParentDir))
2957 {
2958 let _ = tokio::fs::remove_dir_all(staging).await;
2960 tracing::warn!(
2961 event = "upload_path_traversal",
2962 upload_id = %upload_id,
2963 path = %entry.path,
2964 "Upload rejected: path traversal component detected"
2965 );
2966 return Err((
2967 StatusCode::BAD_REQUEST,
2968 Json(serde_json::json!({"error": "Upload rejected: path traversal detected"})),
2969 )
2970 .into_response());
2971 }
2972
2973 if let Err(resp) =
2974 stage_decoded_entry(entry, staging, &mut total_bytes, &mut project_root).await
2975 {
2976 let _ = tokio::fs::remove_dir_all(staging).await;
2977 return Err(resp);
2978 }
2979 }
2980
2981 Ok((files.len(), project_root))
2982}
2983
2984fn parse_tarball_size_caps() -> (u64, u64) {
2987 let compressed = std::env::var("SLOC_MAX_TARBALL_MB")
2988 .ok()
2989 .and_then(|v| v.parse().ok())
2990 .unwrap_or(2048_u64)
2991 * 1024
2992 * 1024;
2993 let decompressed = std::env::var("SLOC_MAX_TARBALL_DECOMPRESSED_MB")
2994 .ok()
2995 .and_then(|v| v.parse().ok())
2996 .unwrap_or(10_240_u64)
2997 * 1024
2998 * 1024;
2999 (compressed, decompressed)
3000}
3001
3002fn tarball_http_body_limit_bytes() -> usize {
3006 std::env::var("SLOC_MAX_TARBALL_MB")
3007 .ok()
3008 .and_then(|v| v.parse::<usize>().ok())
3009 .unwrap_or(2048)
3010 .saturating_mul(1024 * 1024)
3011}
3012
3013#[allow(clippy::result_large_err)] async fn stream_body_to_file(
3018 body: axum::body::Body,
3019 dest_path: &Path,
3020 max_bytes: u64,
3021) -> Result<u64, Response> {
3022 use http_body_util::BodyExt as _;
3023 use tokio::io::AsyncWriteExt as _;
3024
3025 let mut file = match tokio::fs::File::create(dest_path).await {
3026 Ok(f) => f,
3027 Err(e) => {
3028 tracing::error!(
3029 event = "upload_io_error",
3030 "failed to create tarball temp file: {e}"
3031 );
3032 return Err((
3033 StatusCode::INTERNAL_SERVER_ERROR,
3034 Json(serde_json::json!({"error": "Upload initialization failed"})),
3035 )
3036 .into_response());
3037 }
3038 };
3039
3040 let mut body = body;
3041 let mut written: u64 = 0;
3042 loop {
3043 match body.frame().await {
3044 None => break,
3045 Some(Err(e)) => {
3046 let _ = tokio::fs::remove_file(dest_path).await;
3047 return Err((
3048 StatusCode::BAD_REQUEST,
3049 Json(serde_json::json!({"error": format!("Stream error: {e}")})),
3050 )
3051 .into_response());
3052 }
3053 Some(Ok(frame)) => {
3054 if let Ok(data) = frame.into_data() {
3055 written += data.len() as u64;
3056 if written > max_bytes {
3057 let _ = tokio::fs::remove_file(dest_path).await;
3058 return Err((
3059 StatusCode::PAYLOAD_TOO_LARGE,
3060 Json(serde_json::json!({"error": "Tarball exceeds the allowed size limit"})),
3061 )
3062 .into_response());
3063 }
3064 if let Err(e) = file.write_all(&data).await {
3065 let _ = tokio::fs::remove_file(dest_path).await;
3066 tracing::error!(event = "upload_io_error", "tarball write error: {e}");
3067 return Err((
3068 StatusCode::INTERNAL_SERVER_ERROR,
3069 Json(serde_json::json!({"error": "Upload write failed"})),
3070 )
3071 .into_response());
3072 }
3073 }
3074 }
3075 }
3076 }
3077 drop(file);
3078 Ok(written)
3079}
3080
3081#[allow(clippy::result_large_err)] async fn extract_tarball_to_staging(
3086 tarball_path: &Path,
3087 staging: &Path,
3088 max_decompressed_bytes: u64,
3089) -> Result<(), Response> {
3090 let staging_clone = staging.to_path_buf();
3091 let tarball_clone = tarball_path.to_path_buf();
3092 let extract_result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
3093 let file = std::fs::File::open(&tarball_clone)?;
3094 let gz = flate2::read::GzDecoder::new(std::io::BufReader::new(file));
3095 let limited = SizeLimitReader {
3096 inner: gz,
3097 remaining: max_decompressed_bytes,
3098 };
3099 let mut archive = tar::Archive::new(limited);
3100 archive.set_overwrite(true);
3101 archive.set_preserve_permissions(false);
3102 std::fs::create_dir_all(&staging_clone)?;
3103 archive.unpack(&staging_clone)?;
3104 Ok(())
3105 })
3106 .await;
3107 let _ = tokio::fs::remove_file(tarball_path).await;
3108
3109 match extract_result {
3110 Ok(Ok(())) => Ok(()),
3111 Ok(Err(e)) => {
3112 let _ = tokio::fs::remove_dir_all(staging).await;
3113 let is_size_limit = e.to_string().contains("decompressed size limit exceeded");
3114 tracing::warn!(
3115 event = "upload_extract_error",
3116 "tarball extraction failed: {e:#}"
3117 );
3118 let (status, msg) = if is_size_limit {
3119 (
3120 StatusCode::PAYLOAD_TOO_LARGE,
3121 "Archive exceeds the decompressed size limit",
3122 )
3123 } else {
3124 (StatusCode::BAD_REQUEST, "Failed to extract archive")
3125 };
3126 Err((status, Json(serde_json::json!({"error": msg}))).into_response())
3127 }
3128 Err(e) => {
3129 let _ = tokio::fs::remove_dir_all(staging).await;
3130 tracing::error!(
3131 event = "upload_extract_panic",
3132 "tarball extraction task panicked: {e}"
3133 );
3134 Err((
3135 StatusCode::INTERNAL_SERVER_ERROR,
3136 Json(serde_json::json!({"error": "Archive extraction failed"})),
3137 )
3138 .into_response())
3139 }
3140 }
3141}
3142
3143async fn find_single_top_dir(staging: &Path) -> Option<PathBuf> {
3147 let mut entries = tokio::fs::read_dir(staging).await.ok()?;
3148 let first = entries.next_entry().await.ok()??;
3149 if !first.path().is_dir() {
3150 return None;
3151 }
3152 if entries.next_entry().await.unwrap_or(None).is_some() {
3153 return None;
3154 }
3155 Some(first.path())
3156}
3157
3158#[derive(Deserialize)]
3165struct UploadDirRequest {
3166 files: Vec<UploadedFile>,
3167 upload_id: Option<String>,
3170}
3171
3172#[derive(Deserialize)]
3173struct UploadedFile {
3174 path: String,
3176 content: String,
3178}
3179
3180async fn upload_directory_handler(
3190 State(state): State<AppState>,
3191 Json(body): Json<UploadDirRequest>,
3192) -> Response {
3193 if !state.server_mode {
3194 return StatusCode::NOT_FOUND.into_response();
3195 }
3196 if let Err(resp) = validate_upload_dir_request(&body) {
3197 return resp;
3198 }
3199 let (upload_id, staging) = resolve_or_create_staging(body.upload_id.as_deref());
3202 match write_upload_files(&body.files, &staging, &upload_id).await {
3203 Ok((file_count, project_root)) => {
3204 let scan_root = project_root.unwrap_or_else(|| staging.clone());
3205 Json(serde_json::json!({
3206 "tmp_path": scan_root.to_string_lossy(),
3207 "file_count": file_count,
3208 "upload_id": upload_id.clone()
3209 }))
3210 .into_response()
3211 }
3212 Err(resp) => resp,
3213 }
3214}
3215
3216#[derive(Deserialize)]
3218struct UploadFileRequest {
3219 filename: String,
3221 content: String,
3223}
3224
3225async fn upload_file_handler(
3231 State(state): State<AppState>,
3232 Json(body): Json<UploadFileRequest>,
3233) -> Response {
3234 const MAX_FILE_BYTES: usize = 10 * 1024 * 1024; if !state.server_mode {
3237 return StatusCode::NOT_FOUND.into_response();
3238 }
3239
3240 let Ok(data) = base64::Engine::decode(
3241 &base64::engine::general_purpose::STANDARD,
3242 body.content.as_bytes(),
3243 ) else {
3244 return (
3245 StatusCode::BAD_REQUEST,
3246 Json(serde_json::json!({"error": "Invalid base64 content"})),
3247 )
3248 .into_response();
3249 };
3250
3251 if data.len() > MAX_FILE_BYTES {
3252 return (
3253 StatusCode::PAYLOAD_TOO_LARGE,
3254 Json(serde_json::json!({"error": "File exceeds the 10 MB limit"})),
3255 )
3256 .into_response();
3257 }
3258
3259 let filename = std::path::Path::new(&body.filename)
3261 .file_name()
3262 .map_or_else(|| "upload".to_owned(), |n| n.to_string_lossy().into_owned());
3263
3264 let upload_id = uuid::Uuid::new_v4();
3265 let staging = std::env::temp_dir()
3266 .join("oxide-sloc-uploads")
3267 .join(upload_id.to_string());
3268
3269 if tokio::fs::create_dir_all(&staging).await.is_err() {
3270 return (
3271 StatusCode::INTERNAL_SERVER_ERROR,
3272 Json(serde_json::json!({"error": "Failed to create staging directory"})),
3273 )
3274 .into_response();
3275 }
3276
3277 let dest = staging.join(&filename);
3278 if tokio::fs::write(&dest, &data).await.is_err() {
3279 let _ = tokio::fs::remove_dir_all(&staging).await;
3280 return (
3281 StatusCode::INTERNAL_SERVER_ERROR,
3282 Json(serde_json::json!({"error": "Failed to write uploaded file"})),
3283 )
3284 .into_response();
3285 }
3286
3287 Json(serde_json::json!({
3288 "tmp_path": dest.to_string_lossy(),
3289 "upload_id": upload_id.to_string()
3290 }))
3291 .into_response()
3292}
3293
3294struct SizeLimitReader<R> {
3310 inner: R,
3311 remaining: u64,
3312}
3313impl<R: std::io::Read> std::io::Read for SizeLimitReader<R> {
3314 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
3315 if self.remaining == 0 {
3316 return Err(std::io::Error::other("decompressed size limit exceeded"));
3317 }
3318 let n = self.inner.read(buf)?;
3319 self.remaining = self.remaining.saturating_sub(n as u64);
3320 Ok(n)
3321 }
3322}
3323
3324async fn upload_tarball_handler(
3325 State(state): State<AppState>,
3326 request: axum::extract::Request,
3327) -> Response {
3328 if !state.server_mode {
3329 return StatusCode::NOT_FOUND.into_response();
3330 }
3331
3332 let upload_id = uuid::Uuid::new_v4().to_string();
3333 let upload_base = upload_base_dir();
3334 let tarball_path = upload_base.join(format!("{upload_id}.tar.gz"));
3335 let staging = upload_staging_path(&upload_id);
3336 let (max_compressed_bytes, max_decompressed_bytes) = parse_tarball_size_caps();
3337
3338 if let Err(e) = tokio::fs::create_dir_all(&upload_base).await {
3339 tracing::error!(
3340 event = "upload_io_error",
3341 "failed to create upload base dir: {e}"
3342 );
3343 return (
3344 StatusCode::INTERNAL_SERVER_ERROR,
3345 Json(serde_json::json!({"error": "Upload initialization failed"})),
3346 )
3347 .into_response();
3348 }
3349
3350 let compressed_bytes =
3352 match stream_body_to_file(request.into_body(), &tarball_path, max_compressed_bytes).await {
3353 Ok(n) => n,
3354 Err(resp) => return resp,
3355 };
3356
3357 if let Err(resp) =
3359 extract_tarball_to_staging(&tarball_path, &staging, max_decompressed_bytes).await
3360 {
3361 return resp;
3362 }
3363
3364 let scan_root = find_single_top_dir(&staging)
3369 .await
3370 .unwrap_or_else(|| staging.clone());
3371
3372 let original_bytes = tokio::task::spawn_blocking({
3374 let p = scan_root.clone();
3375 move || dir_size_bytes(&p)
3376 })
3377 .await
3378 .unwrap_or(0);
3379
3380 Json(serde_json::json!({
3381 "tmp_path": scan_root.to_string_lossy(),
3382 "upload_id": upload_id,
3383 "compressed_bytes": compressed_bytes,
3384 "original_bytes": original_bytes,
3385 }))
3386 .into_response()
3387}
3388
3389#[derive(Deserialize)]
3390struct LocateReportForm {
3391 file_path: String,
3392 #[serde(default)]
3393 redirect_url: Option<String>,
3394 #[serde(default)]
3395 expected_run_id: Option<String>,
3396}
3397
3398fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
3400 let html = ErrorTemplate {
3401 message: message.into(),
3402 last_report_url: Some("/view-reports".to_string()),
3403 last_report_label: Some("View Reports".to_string()),
3404 run_id: None,
3405 error_code: None,
3406 csp_nonce: csp_nonce.to_owned(),
3407 version: env!("CARGO_PKG_VERSION"),
3408 }
3409 .render()
3410 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3411 Html(html).into_response()
3412}
3413
3414fn registry_entry_from_run(
3416 run: &AnalysisRun,
3417 json_path: PathBuf,
3418 html_path: PathBuf,
3419) -> RegistryEntry {
3420 let project_label = run.input_roots.first().map_or_else(
3421 || "Unknown Project".to_string(),
3422 |r| sanitize_project_label(r),
3423 );
3424 RegistryEntry {
3425 run_id: run.tool.run_id.clone(),
3426 timestamp_utc: run.tool.timestamp_utc,
3427 project_label,
3428 input_roots: run.input_roots.clone(),
3429 json_path: Some(json_path),
3430 html_path: Some(html_path),
3431 pdf_path: None,
3432 summary: ScanSummarySnapshot::from(&run.summary_totals),
3433 csv_path: None,
3434 xlsx_path: None,
3435 git_branch: None,
3436 git_commit: None,
3437 git_commit_long: None,
3438 git_author: None,
3439 git_tags: None,
3440 git_nearest_tag: None,
3441 git_commit_date: None,
3442 }
3443}
3444
3445pub(crate) async fn register_artifacts_in_registry(
3448 state: &AppState,
3449 label: &str,
3450 run: &AnalysisRun,
3451 artifacts: &RunArtifacts,
3452) {
3453 let Some(json_path) = artifacts.json_path.clone() else {
3454 return;
3455 };
3456 let Some(html_path) = artifacts.html_path.clone() else {
3457 return;
3458 };
3459 let mut entry = registry_entry_from_run(run, json_path, html_path);
3460 entry.project_label = label.to_owned();
3461 let mut reg = state.registry.lock().await;
3462 reg.add_entry(entry);
3463 let _ = reg.save(&state.registry_path);
3464}
3465
3466fn is_html_report_file(p: &Path) -> bool {
3467 p.is_file()
3468 && p.extension()
3469 .and_then(|x| x.to_str())
3470 .is_some_and(|x| x.eq_ignore_ascii_case("html"))
3471 && p.file_name()
3472 .and_then(|n| n.to_str())
3473 .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
3474}
3475
3476fn find_html_report_in_dir(dir: &Path) -> Option<PathBuf> {
3477 fs::read_dir(dir)
3478 .ok()?
3479 .flatten()
3480 .map(|e| e.path())
3481 .find(|p| is_html_report_file(p))
3482}
3483
3484fn find_html_report_in_tree(dir: &Path) -> Option<PathBuf> {
3485 if let Some(f) = find_html_report_in_dir(dir) {
3486 return Some(f);
3487 }
3488 if let Ok(rd) = fs::read_dir(dir) {
3489 for entry in rd.flatten() {
3490 let sub = entry.path();
3491 if sub.is_dir() {
3492 if let Some(f) = find_html_report_in_dir(&sub) {
3493 return Some(f);
3494 }
3495 }
3496 }
3497 }
3498 None
3499}
3500
3501#[allow(clippy::result_large_err)]
3506fn validate_locate_request(
3507 state: &AppState,
3508 file_path: &str,
3509 csp_nonce: &str,
3510) -> Result<(PathBuf, PathBuf), Response> {
3511 let raw = PathBuf::from(file_path);
3512
3513 let html_path = if raw.is_dir() {
3515 let found = find_html_report_in_tree(&raw);
3516 match found {
3517 Some(f) => strip_unc_prefix(fs::canonicalize(&f).unwrap_or(f)),
3518 None => {
3519 return Err(locate_report_error(
3520 "No HTML report file found in the selected folder.\n\nMake sure you selected \
3521 the folder that contains your scan output (result_*.html or report_*.html).",
3522 csp_nonce,
3523 ));
3524 }
3525 }
3526 } else {
3527 let file_ext = raw
3528 .extension()
3529 .and_then(|e| e.to_str())
3530 .unwrap_or("")
3531 .to_ascii_lowercase();
3532 if file_ext != "html" {
3533 return Err(locate_report_error(
3534 "Please select the scan output folder, or an .html report file directly.",
3535 csp_nonce,
3536 ));
3537 }
3538 match fs::canonicalize(&raw) {
3539 Ok(p) => strip_unc_prefix(p),
3540 Err(_) => {
3541 return Err(locate_report_error(
3542 "Report file not found or path is invalid.",
3543 csp_nonce,
3544 ));
3545 }
3546 }
3547 };
3548
3549 if state.server_mode {
3550 let output_root = resolve_output_root(None);
3551 let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
3552 if !html_path.starts_with(&canonical_root) {
3553 return Err(locate_report_error(
3554 "Report file must be within the configured output directory.",
3555 csp_nonce,
3556 ));
3557 }
3558 }
3559 let parent = match html_path.parent() {
3560 Some(p) => p.to_path_buf(),
3561 None => {
3562 return Err(locate_report_error(
3563 "Report file has no parent directory.",
3564 csp_nonce,
3565 ));
3566 }
3567 };
3568 Ok((html_path, parent))
3569}
3570
3571fn locate_handler_err(want_json: bool, msg: String, csp_nonce: &str) -> Response {
3573 if want_json {
3574 (
3575 StatusCode::UNPROCESSABLE_ENTITY,
3576 axum::Json(serde_json::json!({"ok": false, "message": msg})),
3577 )
3578 .into_response()
3579 } else {
3580 locate_report_error(msg, csp_nonce)
3581 }
3582}
3583
3584fn redirect_or_json_ok(want_json: bool, redirect: &str) -> Response {
3586 if want_json {
3587 axum::Json(serde_json::json!({"ok": true, "redirect": redirect})).into_response()
3588 } else {
3589 axum::response::Redirect::to(redirect).into_response()
3590 }
3591}
3592
3593fn find_json_run_by_id(candidates: &[PathBuf], expected: &str) -> Option<(PathBuf, String)> {
3596 for jpath in candidates {
3597 if let Ok(run) = read_json(jpath) {
3598 if expected.is_empty() || run.tool.run_id == expected {
3599 return Some((jpath.clone(), run.tool.run_id));
3600 }
3601 }
3602 }
3603 None
3604}
3605
3606fn resolve_scan_root(html_path: &Path, parent: &Path) -> PathBuf {
3607 html_path
3608 .parent()
3609 .and_then(|p| p.parent())
3610 .map_or_else(|| parent.to_path_buf(), std::path::Path::to_path_buf)
3611}
3612
3613fn gather_json_candidates(scan_root: &Path, parent: &Path) -> Vec<PathBuf> {
3614 let mut hits = collect_result_json_candidates(scan_root);
3615 if hits.is_empty() {
3616 hits = collect_result_json_candidates(parent);
3617 }
3618 hits.sort();
3619 hits
3620}
3621
3622#[allow(clippy::too_many_lines)]
3623async fn locate_report_handler(
3624 State(state): State<AppState>,
3625 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3626 headers: axum::http::HeaderMap,
3627 Form(form): Form<LocateReportForm>,
3628) -> impl IntoResponse {
3629 let want_json = headers
3630 .get(axum::http::header::ACCEPT)
3631 .and_then(|v| v.to_str().ok())
3632 .is_some_and(|v| v.contains("application/json"));
3633
3634 let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
3635 Ok(v) => v,
3636 Err(resp) => {
3637 if want_json {
3638 return locate_handler_err(
3639 true,
3640 "No HTML report file found in the selected folder. \
3641 Make sure you selected the folder that contains your \
3642 scan output (look for the folder with html/, json/, pdf/ subdirs)."
3643 .to_string(),
3644 &csp_nonce,
3645 );
3646 }
3647 return resp;
3648 }
3649 };
3650
3651 let scan_root_owned = resolve_scan_root(&html_path, &parent);
3654 let scan_root: &Path = &scan_root_owned;
3655 let json_candidates = gather_json_candidates(scan_root, &parent);
3656
3657 let expected_run_id = form
3659 .expected_run_id
3660 .as_deref()
3661 .unwrap_or("")
3662 .trim()
3663 .to_string();
3664
3665 let matched_json = find_json_run_by_id(&json_candidates, &expected_run_id);
3666
3667 if matched_json.is_none() && !json_candidates.is_empty() && !expected_run_id.is_empty() {
3669 let actual = json_candidates
3670 .iter()
3671 .find_map(|p| read_json(p).ok().map(|r| r.tool.run_id))
3672 .unwrap_or_else(|| "unknown".to_string());
3673 return locate_handler_err(
3674 want_json,
3675 format!(
3676 "This folder contains a different scan.\n\n\
3677 Expected run ID : {expected_run_id}\n\
3678 Found run ID : {actual}\n\n\
3679 Please select the folder that contains the correct scan output."
3680 ),
3681 &csp_nonce,
3682 );
3683 }
3684
3685 let safe_redirect = form
3686 .redirect_url
3687 .as_deref()
3688 .filter(|u| u.starts_with('/') && !u.starts_with("//"))
3689 .unwrap_or("/view-reports?linked=1")
3690 .to_string();
3691
3692 let mut reg = state.registry.lock().await;
3693
3694 if let Some((json_path, run_id)) = matched_json {
3695 if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
3697 entry.html_path = Some(html_path);
3698 entry.json_path = Some(json_path);
3699 let _ = reg.save(&state.registry_path);
3700 drop(reg);
3701 state.artifacts.lock().await.remove(&run_id);
3703 return redirect_or_json_ok(want_json, &safe_redirect);
3704 }
3705 match read_json(&json_path) {
3707 Ok(run) => {
3708 let entry = registry_entry_from_run(&run, json_path, html_path);
3709 reg.add_entry(entry);
3710 let _ = reg.save(&state.registry_path);
3711 drop(reg);
3712 state.artifacts.lock().await.remove(&run_id);
3713 return redirect_or_json_ok(want_json, &safe_redirect);
3714 }
3715 Err(e) => {
3716 drop(reg);
3717 return locate_handler_err(
3718 want_json,
3719 format!(
3720 "Found the scan folder but could not parse the result JSON.\n\n\
3721 The file may have been saved by an older version of OxideSLOC. \
3722 Re-running the analysis will create a fresh, compatible record.\n\n\
3723 Error: {e}"
3724 ),
3725 &csp_nonce,
3726 );
3727 }
3728 }
3729 }
3730
3731 if let Some(entry) = reg
3733 .entries
3734 .iter_mut()
3735 .find(|e| !expected_run_id.is_empty() && e.run_id == expected_run_id)
3736 {
3737 entry.html_path = Some(html_path.clone());
3738 let _ = reg.save(&state.registry_path);
3739 drop(reg);
3740 state.artifacts.lock().await.remove(&expected_run_id);
3741 return redirect_or_json_ok(want_json, &safe_redirect);
3742 }
3743
3744 drop(reg);
3745 let hint = if state.server_mode {
3746 String::new()
3747 } else {
3748 format!(
3749 "\n\nSearched folder : {}\nHTML found : {}",
3750 scan_root.display(),
3751 html_path.display()
3752 )
3753 };
3754 locate_handler_err(
3755 want_json,
3756 format!(
3757 "Could not link this report.\n\n\
3758 No result_*.json was found in the selected folder. \
3759 Make sure you selected the top-level scan output folder \
3760 (the one that contains html/, json/, pdf/ subfolders).{hint}"
3761 ),
3762 &csp_nonce,
3763 )
3764}
3765
3766fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
3768 fs::read_dir(dir)
3769 .ok()?
3770 .flatten()
3771 .map(|e| e.path())
3772 .find(|p| {
3773 p.is_file()
3774 && p.file_stem()
3775 .and_then(|n| n.to_str())
3776 .is_some_and(|n| n.starts_with("result"))
3777 && p.extension()
3778 .is_some_and(|e| e.eq_ignore_ascii_case("json"))
3779 })
3780}
3781
3782#[derive(Deserialize)]
3783struct LocateReportsDirForm {
3784 folder_path: String,
3785}
3786
3787#[allow(clippy::too_many_lines)] async fn locate_reports_dir_handler(
3789 State(state): State<AppState>,
3790 Form(form): Form<LocateReportsDirForm>,
3791) -> impl IntoResponse {
3792 if state.server_mode {
3793 return StatusCode::NOT_FOUND.into_response();
3794 }
3795 let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
3796 Ok(p) => strip_unc_prefix(p),
3797 Err(_) => {
3798 return axum::response::Redirect::to(
3799 "/view-reports?error=Folder+not+found+or+path+is+invalid.",
3800 )
3801 .into_response();
3802 }
3803 };
3804 if !folder.is_dir() {
3805 return axum::response::Redirect::to(
3806 "/view-reports?error=Selected+path+is+not+a+directory.",
3807 )
3808 .into_response();
3809 }
3810
3811 let candidates = collect_result_json_candidates(&folder);
3812
3813 if candidates.is_empty() {
3814 return axum::response::Redirect::to(
3815 "/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
3816 )
3817 .into_response();
3818 }
3819
3820 let mut linked_count: usize = 0;
3821 let mut reg = state.registry.lock().await;
3822 for json_path in candidates {
3823 let Some(parent) = json_path.parent().map(PathBuf::from) else {
3824 continue;
3825 };
3826 if is_dir_already_registered(®, &parent) {
3827 continue;
3828 }
3829 let Some(entry) = build_registry_entry_from_json(json_path) else {
3830 continue;
3831 };
3832 reg.add_entry(entry);
3833 linked_count += 1;
3834 }
3835 let _ = reg.save(&state.registry_path);
3836 drop(reg);
3837
3838 if linked_count == 0 {
3839 return axum::response::Redirect::to(
3840 "/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
3841 )
3842 .into_response();
3843 }
3844 axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
3845}
3846
3847#[derive(Deserialize)]
3848struct RelocateScanForm {
3849 run_id: String,
3850 folder_path: String,
3851 redirect_url: String,
3852}
3853
3854fn relocate_folder_err(
3857 want_json: bool,
3858 status: StatusCode,
3859 msg: &str,
3860 run_id: &str,
3861 folder_hint: &str,
3862 redirect_url: &str,
3863 csp_nonce: &str,
3864) -> Response {
3865 if want_json {
3866 (
3867 status,
3868 axum::Json(serde_json::json!({"ok": false, "message": msg})),
3869 )
3870 .into_response()
3871 } else {
3872 missing_scan_relocate_response(msg, run_id, folder_hint, redirect_url, false, csp_nonce)
3873 }
3874}
3875
3876#[allow(clippy::too_many_lines)]
3877async fn relocate_scan_handler(
3878 State(state): State<AppState>,
3879 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3880 headers: axum::http::HeaderMap,
3881 Form(form): Form<RelocateScanForm>,
3882) -> impl IntoResponse {
3883 let want_json = headers
3884 .get(axum::http::header::ACCEPT)
3885 .and_then(|v| v.to_str().ok())
3886 .is_some_and(|v| v.contains("application/json"));
3887 if state.server_mode {
3888 return StatusCode::NOT_FOUND.into_response();
3889 }
3890
3891 let run_id = form.run_id.trim().to_string();
3892 let redirect_url = form.redirect_url.trim().to_string();
3893
3894 let run_exists = {
3895 let reg = state.registry.lock().await;
3896 reg.find_by_run_id(&run_id).is_some()
3897 };
3898 if !run_exists {
3899 if want_json {
3900 return (
3901 StatusCode::NOT_FOUND,
3902 axum::Json(serde_json::json!({
3903 "ok": false,
3904 "message": format!("Run ID '{run_id}' not found in registry.")
3905 })),
3906 )
3907 .into_response();
3908 }
3909 let html = ErrorTemplate {
3910 message: format!("Run ID '{run_id}' not found in registry."),
3911 last_report_url: Some("/compare-scans".to_string()),
3912 last_report_label: Some("Compare Scans".to_string()),
3913 run_id: Some(run_id.clone()),
3914 error_code: Some(404),
3915 csp_nonce: csp_nonce.clone(),
3916 version: env!("CARGO_PKG_VERSION"),
3917 }
3918 .render()
3919 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3920 return Html(html).into_response();
3921 }
3922
3923 let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
3924 Ok(p) => strip_unc_prefix(p),
3925 Err(_) => {
3926 return relocate_folder_err(
3927 want_json,
3928 StatusCode::UNPROCESSABLE_ENTITY,
3929 "Folder not found or path is invalid.",
3930 &run_id,
3931 form.folder_path.trim(),
3932 &redirect_url,
3933 &csp_nonce,
3934 );
3935 }
3936 };
3937 if !folder.is_dir() {
3938 return relocate_folder_err(
3939 want_json,
3940 StatusCode::UNPROCESSABLE_ENTITY,
3941 "Selected path is not a directory.",
3942 &run_id,
3943 &folder.display().to_string(),
3944 &redirect_url,
3945 &csp_nonce,
3946 );
3947 }
3948
3949 let json_candidates = find_result_files_by_ext(&folder, "json");
3950 if json_candidates.is_empty() {
3951 let msg = format!(
3952 "No result JSON files found in the selected folder.\nSearched: {}",
3953 folder.display()
3954 );
3955 return relocate_folder_err(
3956 want_json,
3957 StatusCode::UNPROCESSABLE_ENTITY,
3958 &msg,
3959 &run_id,
3960 &folder.display().to_string(),
3961 &redirect_url,
3962 &csp_nonce,
3963 );
3964 }
3965
3966 let Some(json_path) = find_matching_run_json(&json_candidates, &run_id) else {
3967 let msg = format!(
3968 "No matching scan found in the selected folder.\n\
3969 The JSON files present do not contain run ID: {run_id}\n\
3970 Searched: {}",
3971 folder.display()
3972 );
3973 return relocate_folder_err(
3974 want_json,
3975 StatusCode::UNPROCESSABLE_ENTITY,
3976 &msg,
3977 &run_id,
3978 &folder.display().to_string(),
3979 &redirect_url,
3980 &csp_nonce,
3981 );
3982 };
3983
3984 let html_path = find_result_files_by_ext(&folder, "html").into_iter().next();
3985 let pdf_path = find_result_files_by_ext(&folder, "pdf").into_iter().next();
3986 update_run_file_paths(&state, &run_id, json_path, html_path, pdf_path).await;
3987
3988 let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
3989 redirect_url
3990 } else {
3991 "/compare-scans".to_string()
3992 };
3993 redirect_or_json_ok(want_json, &safe_redirect)
3994}
3995
3996fn find_result_files_by_ext(folder: &std::path::Path, ext: &str) -> Vec<PathBuf> {
3997 let mut out = Vec::new();
3998 collect_scan_files_by_ext(folder, ext, &mut out);
3999 if let Ok(rd) = fs::read_dir(folder) {
4000 for entry in rd.flatten() {
4001 let sub = entry.path();
4002 if sub.is_dir() {
4003 collect_scan_files_by_ext(&sub, ext, &mut out);
4004 }
4005 }
4006 }
4007 out
4008}
4009
4010fn collect_scan_files_by_ext(dir: &std::path::Path, ext: &str, out: &mut Vec<PathBuf>) {
4011 let Ok(rd) = fs::read_dir(dir) else { return };
4012 for entry in rd.flatten() {
4013 let p = entry.path();
4014 if p.is_file()
4015 && p.file_stem()
4016 .and_then(|n| n.to_str())
4017 .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
4018 && p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext))
4019 {
4020 out.push(p);
4021 }
4022 }
4023}
4024
4025fn find_matching_run_json(candidates: &[PathBuf], run_id: &str) -> Option<PathBuf> {
4026 candidates
4027 .iter()
4028 .find(|c| read_json(c).ok().is_some_and(|r| r.tool.run_id == run_id))
4029 .cloned()
4030}
4031
4032fn output_folder_hint(json_path: &std::path::Path) -> String {
4037 let Some(direct_parent) = json_path.parent() else {
4038 return String::new();
4039 };
4040 let parent_name = direct_parent
4041 .file_name()
4042 .and_then(|n| n.to_str())
4043 .unwrap_or("");
4044 if matches!(parent_name, "json" | "html" | "pdf" | "excel") {
4045 direct_parent.parent().map_or_else(
4046 || direct_parent.display().to_string(),
4047 |p| p.display().to_string(),
4048 )
4049 } else {
4050 direct_parent.display().to_string()
4051 }
4052}
4053
4054async fn update_run_file_paths(
4055 state: &AppState,
4056 run_id: &str,
4057 json_path: PathBuf,
4058 html_path: Option<PathBuf>,
4059 pdf_path: Option<PathBuf>,
4060) {
4061 {
4062 let mut reg = state.registry.lock().await;
4063 if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
4064 entry.json_path = Some(json_path.clone());
4065 if let Some(ref hp) = html_path {
4066 entry.html_path = Some(hp.clone());
4067 }
4068 if let Some(ref pp) = pdf_path {
4069 entry.pdf_path = Some(pp.clone());
4070 }
4071 }
4072 let _ = reg.save(&state.registry_path);
4073 }
4074 {
4077 let mut map = state.artifacts.lock().await;
4078 if let Some(arts) = map.get_mut(run_id) {
4079 arts.json_path = Some(json_path);
4080 if let Some(hp) = html_path {
4081 arts.html_path = Some(hp);
4082 }
4083 if let Some(pp) = pdf_path {
4084 arts.pdf_path = Some(pp);
4085 }
4086 }
4087 }
4088}
4089
4090fn missing_scan_relocate_response(
4091 message: &str,
4092 run_id: &str,
4093 folder_hint: &str,
4094 redirect_url: &str,
4095 server_mode: bool,
4096 csp_nonce: &str,
4097) -> axum::response::Response {
4098 let html = RelocateScanTemplate {
4099 message: message.to_string(),
4100 run_id: run_id.to_string(),
4101 folder_hint: folder_hint.to_string(),
4102 redirect_url: redirect_url.to_string(),
4103 server_mode,
4104 csp_nonce: csp_nonce.to_owned(),
4105 version: env!("CARGO_PKG_VERSION"),
4106 }
4107 .render()
4108 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
4109 (StatusCode::NOT_FOUND, Html(html)).into_response()
4110}
4111
4112fn find_file_by_ext(dir: &Path, ext: &str) -> Option<PathBuf> {
4116 fs::read_dir(dir)
4117 .ok()?
4118 .flatten()
4119 .map(|e| e.path())
4120 .find(|p| {
4121 p.is_file()
4122 && p.extension()
4123 .and_then(|e| e.to_str())
4124 .is_some_and(|e| e.eq_ignore_ascii_case(ext))
4125 })
4126}
4127
4128fn subdir_result_json_candidates(sub: &std::path::Path) -> Vec<PathBuf> {
4132 let mut out = Vec::new();
4133 if let Some(j) = find_result_json_in_dir(sub) {
4134 out.push(j);
4135 }
4136 let json_sub = sub.join("json");
4137 if json_sub.is_dir() {
4138 if let Some(j) = find_result_json_in_dir(&json_sub) {
4139 out.push(j);
4140 }
4141 }
4142 out
4143}
4144
4145fn collect_result_json_candidates(folder: &std::path::Path) -> Vec<PathBuf> {
4146 let mut candidates = Vec::new();
4147 if let Some(j) = find_result_json_in_dir(folder) {
4148 candidates.push(j);
4149 }
4150 let Ok(dir_entries) = fs::read_dir(folder) else {
4151 return candidates;
4152 };
4153 for entry in dir_entries.flatten() {
4154 let sub = entry.path();
4155 if sub.is_dir() {
4156 candidates.extend(subdir_result_json_candidates(&sub));
4157 }
4158 }
4159 candidates
4160}
4161
4162fn is_dir_already_registered(reg: &ScanRegistry, parent: &std::path::Path) -> bool {
4163 reg.entries.iter().any(|e| {
4164 let dir_match = e
4165 .json_path
4166 .as_ref()
4167 .and_then(|p| p.parent())
4168 .is_some_and(|p| p == parent)
4169 || e.html_path
4170 .as_ref()
4171 .and_then(|p| p.parent())
4172 .is_some_and(|p| p == parent);
4173 dir_match
4174 && (e.json_path.as_ref().is_some_and(|p| p.exists())
4175 || e.html_path.as_ref().is_some_and(|p| p.exists()))
4176 })
4177}
4178
4179fn build_registry_entry_from_json(json_path: PathBuf) -> Option<RegistryEntry> {
4180 let json_dir = json_path.parent()?.to_path_buf();
4181 let (html_path, pdf_path, csv_path, xlsx_path) =
4184 if json_dir.file_name().and_then(|n| n.to_str()) == Some("json") {
4185 let scan_root = json_dir.parent()?;
4186 let html = find_html_report_in_dir(&scan_root.join("html"))
4187 .or_else(|| find_html_report_in_dir(scan_root));
4188 let pdf = find_file_by_ext(&scan_root.join("pdf"), "pdf");
4189 let csv = find_file_by_ext(&scan_root.join("excel"), "csv");
4190 let xlsx = find_file_by_ext(&scan_root.join("excel"), "xlsx");
4191 (html, pdf, csv, xlsx)
4192 } else {
4193 let html = fs::read_dir(&json_dir).ok().and_then(|rd| {
4194 rd.flatten()
4195 .map(|e| e.path())
4196 .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
4197 });
4198 (html, None, None, None)
4199 };
4200 let run = read_json(&json_path).ok()?;
4201 let project_label = run.input_roots.first().map_or_else(
4202 || "Unknown Project".to_string(),
4203 |r| sanitize_project_label(r),
4204 );
4205 Some(RegistryEntry {
4206 run_id: run.tool.run_id.clone(),
4207 timestamp_utc: run.tool.timestamp_utc,
4208 project_label,
4209 input_roots: run.input_roots.clone(),
4210 json_path: Some(json_path),
4211 html_path,
4212 pdf_path,
4213 csv_path,
4214 xlsx_path,
4215 summary: ScanSummarySnapshot::from(&run.summary_totals),
4216 git_branch: run.git_branch.clone(),
4217 git_commit: run.git_commit_short.clone(),
4218 git_commit_long: run.git_commit_long.clone(),
4219 git_author: run.git_commit_author.clone(),
4220 git_tags: run.git_tags.clone(),
4221 git_nearest_tag: run.git_nearest_tag.clone(),
4222 git_commit_date: run.git_commit_date,
4223 })
4224}
4225
4226fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
4229 let mut linked = 0usize;
4230 for json_path in collect_result_json_candidates(folder) {
4231 let Some(parent) = json_path.parent().map(PathBuf::from) else {
4232 continue;
4233 };
4234 if is_dir_already_registered(reg, &parent) {
4235 continue;
4236 }
4237 let Some(entry) = build_registry_entry_from_json(json_path) else {
4238 continue;
4239 };
4240 reg.add_entry(entry);
4241 linked += 1;
4242 }
4243 linked
4244}
4245
4246async fn auto_scan_watched_dirs(state: &AppState) {
4248 let dirs: Vec<PathBuf> = {
4249 let wd = state.watched_dirs.lock().await;
4250 wd.dirs.clone()
4251 };
4252 {
4256 let output_root = resolve_output_root(None);
4257 let mut roots: Vec<PathBuf> = dirs.clone();
4258 if let Ok(canon) = fs::canonicalize(&output_root) {
4259 roots.push(strip_unc_prefix(canon));
4260 }
4261 roots.push(output_root);
4262 let mut reg = state.registry.lock().await;
4263 if reg.retain_under_roots(&roots) > 0 {
4264 let _ = reg.save(&state.registry_path);
4265 }
4266 }
4267 if dirs.is_empty() {
4268 return;
4269 }
4270 let mut reg = state.registry.lock().await;
4271 let mut total = 0usize;
4272 for dir in &dirs {
4273 if dir.is_dir() {
4274 total += scan_folder_into_registry(dir, &mut reg);
4275 }
4276 }
4277 if total > 0 {
4278 let _ = reg.save(&state.registry_path);
4279 }
4280}
4281
4282#[derive(Deserialize)]
4285struct WatchedDirForm {
4286 folder_path: String,
4287 #[serde(default = "default_redirect")]
4288 redirect_to: String,
4289}
4290
4291fn default_redirect() -> String {
4292 "/view-reports".to_string()
4293}
4294
4295#[derive(Deserialize)]
4296struct WatchedDirRefreshForm {
4297 #[serde(default = "default_redirect")]
4298 redirect_to: String,
4299}
4300
4301fn safe_redirect(dest: &str) -> &str {
4305 if dest.starts_with('/') {
4306 dest
4307 } else {
4308 "/"
4309 }
4310}
4311
4312async fn add_watched_dir_handler(
4315 State(state): State<AppState>,
4316 Form(form): Form<WatchedDirForm>,
4317) -> impl IntoResponse {
4318 if state.server_mode {
4319 return StatusCode::NOT_FOUND.into_response();
4320 }
4321 let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
4322 strip_unc_prefix(p)
4323 } else {
4324 let dest = format!(
4325 "{}?error=Folder+not+found+or+path+is+invalid.",
4326 safe_redirect(&form.redirect_to)
4327 );
4328 return axum::response::Redirect::to(&dest).into_response();
4329 };
4330 if !folder.is_dir() {
4331 let dest = format!(
4332 "{}?error=Selected+path+is+not+a+directory.",
4333 safe_redirect(&form.redirect_to)
4334 );
4335 return axum::response::Redirect::to(&dest).into_response();
4336 }
4337
4338 {
4340 let mut wd = state.watched_dirs.lock().await;
4341 wd.add(folder.clone());
4342 let _ = wd.save(&state.watched_dirs_path);
4343 }
4344
4345 let linked = {
4347 let mut reg = state.registry.lock().await;
4348 let n = scan_folder_into_registry(&folder, &mut reg);
4349 if n > 0 {
4350 let _ = reg.save(&state.registry_path);
4351 }
4352 n
4353 };
4354
4355 let dest = if linked > 0 {
4356 format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
4357 } else {
4358 format!(
4359 "{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
4360 safe_redirect(&form.redirect_to)
4361 )
4362 };
4363 axum::response::Redirect::to(&dest).into_response()
4364}
4365
4366async fn remove_watched_dir_handler(
4367 State(state): State<AppState>,
4368 Form(form): Form<WatchedDirForm>,
4369) -> impl IntoResponse {
4370 if state.server_mode {
4371 return StatusCode::NOT_FOUND.into_response();
4372 }
4373 let folder = PathBuf::from(&form.folder_path);
4374 {
4375 let mut wd = state.watched_dirs.lock().await;
4376 wd.remove(&folder);
4377 let _ = wd.save(&state.watched_dirs_path);
4378 }
4379 {
4381 let mut reg = state.registry.lock().await;
4382 if reg.remove_entries_under(&folder) > 0 {
4383 let _ = reg.save(&state.registry_path);
4384 }
4385 }
4386 axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
4387}
4388
4389async fn refresh_watched_dirs_handler(
4390 State(state): State<AppState>,
4391 Form(form): Form<WatchedDirRefreshForm>,
4392) -> impl IntoResponse {
4393 if state.server_mode {
4394 return StatusCode::NOT_FOUND.into_response();
4395 }
4396 let dirs: Vec<PathBuf> = {
4397 let wd = state.watched_dirs.lock().await;
4398 wd.dirs.clone()
4399 };
4400 let mut total = 0usize;
4401 {
4402 let mut reg = state.registry.lock().await;
4403 reg.prune_stale();
4404 for dir in &dirs {
4405 if dir.is_dir() {
4406 total += scan_folder_into_registry(dir, &mut reg);
4407 }
4408 }
4409 let _ = reg.save(&state.registry_path);
4410 }
4411 let dest = if total > 0 {
4412 format!("{}?linked={total}", safe_redirect(&form.redirect_to))
4413 } else {
4414 safe_redirect(&form.redirect_to).to_owned()
4415 };
4416 axum::response::Redirect::to(&dest).into_response()
4417}
4418
4419#[derive(Debug, Deserialize)]
4420struct OpenPathQuery {
4421 path: Option<String>,
4422}
4423
4424fn find_existing_ancestor(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
4425 let mut ancestor = std::path::Path::new(raw);
4426 loop {
4427 match ancestor.parent() {
4428 Some(p) => {
4429 ancestor = p;
4430 if ancestor.is_dir() {
4431 break;
4432 }
4433 }
4434 None => return Err((StatusCode::BAD_REQUEST, "no existing ancestor found")),
4435 }
4436 }
4437 Ok(ancestor.to_path_buf())
4438}
4439
4440async fn resolve_open_target(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
4441 match tokio::fs::canonicalize(raw).await {
4442 Ok(canonical) if canonical.is_file() => canonical
4443 .parent()
4444 .map_or(Err((StatusCode::BAD_REQUEST, "path has no parent")), |p| {
4445 Ok(p.to_path_buf())
4446 }),
4447 Ok(canonical) if canonical.is_dir() => Ok(canonical),
4448 Ok(_) => Err((StatusCode::BAD_REQUEST, "path is not a file or directory")),
4449 Err(_) => find_existing_ancestor(raw),
4450 }
4451}
4452
4453async fn open_path_handler(
4454 State(state): State<AppState>,
4455 Query(query): Query<OpenPathQuery>,
4456) -> impl IntoResponse {
4457 if state.server_mode {
4458 return Json(serde_json::json!({
4459 "server_mode_disabled": true,
4460 "message": "Opening a path in the file manager is only available in local desktop mode."
4461 }))
4462 .into_response();
4463 }
4464 if std::env::var("SLOC_HEADLESS").is_ok() {
4466 return Json(serde_json::json!({ "opened": false, "headless": true })).into_response();
4467 }
4468 let raw = match query.path.as_deref() {
4469 Some(p) if !p.is_empty() => p,
4470 _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
4471 };
4472
4473 let target = match resolve_open_target(raw).await {
4477 Ok(p) => p,
4478 Err((code, msg)) => return (code, msg).into_response(),
4479 };
4480
4481 #[cfg(target_os = "windows")]
4482 win_dialog_focus::open_folder_foreground(target);
4483 #[cfg(target_os = "macos")]
4484 let _ = std::process::Command::new("open")
4485 .arg(&target)
4486 .stdout(Stdio::null())
4487 .stderr(Stdio::null())
4488 .spawn();
4489 #[cfg(target_os = "linux")]
4490 {
4491 let folder_name = target
4492 .file_name()
4493 .and_then(|n| n.to_str())
4494 .map(str::to_owned);
4495 let _ = std::process::Command::new("xdg-open")
4496 .arg(&target)
4497 .stdout(Stdio::null())
4498 .stderr(Stdio::null())
4499 .spawn();
4500 if let Some(name) = folder_name {
4504 std::thread::spawn(move || {
4505 std::thread::sleep(std::time::Duration::from_millis(800));
4506 let _ = std::process::Command::new("wmctrl")
4507 .args(["-a", &name])
4508 .stdout(Stdio::null())
4509 .stderr(Stdio::null())
4510 .spawn();
4511 });
4512 }
4513 }
4514
4515 Json(serde_json::json!({"ok": true})).into_response()
4516}
4517
4518async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
4519 let (content_type, bytes): (&'static str, &'static [u8]) =
4520 match (folder.as_str(), file.as_str()) {
4521 ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
4522 ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
4523 ("icons", "c.png") => ("image/png", IMG_ICON_C),
4524 ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
4525 ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
4526 ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
4527 ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
4528 ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
4529 ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
4530 ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
4531 ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
4532 ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
4533 ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
4534 ("icons", "go.png") => ("image/png", IMG_ICON_GO),
4535 ("icons", "r.png") => ("image/png", IMG_ICON_R),
4536 ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
4537 ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
4538 ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
4539 ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
4540 ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
4541 _ => return StatusCode::NOT_FOUND.into_response(),
4542 };
4543 ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
4544}
4545
4546fn authorize_preview_path(state: &AppState, resolved: &Path) -> Result<(), Html<String>> {
4551 let Ok(canonical) = fs::canonicalize(resolved) else {
4556 if !is_upload_tmp_path(resolved) && !is_sample_path(resolved) {
4557 return Err(Html(
4558 r#"<div class="preview-error">Preview rejected: path could not be resolved to a real directory.</div>"#.to_string()
4559 ));
4560 }
4561 return Ok(());
4562 };
4563 if is_upload_tmp_path(&canonical) || is_sample_path(&canonical) {
4565 return Ok(());
4566 }
4567 let config = &state.base_config;
4568 if config.discovery.allowed_scan_roots.is_empty() {
4569 return Err(Html(
4570 r#"<div class="preview-error">Preview rejected: no allowed_scan_roots configured.</div>"#.to_string()
4571 ));
4572 }
4573 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
4574 fs::canonicalize(root)
4575 .ok()
4576 .is_some_and(|r| canonical.starts_with(&r))
4577 });
4578 if !allowed {
4579 return Err(Html(
4580 r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
4581 ));
4582 }
4583 Ok(())
4584}
4585
4586async fn preview_handler(
4587 State(state): State<AppState>,
4588 Query(query): Query<PreviewQuery>,
4589) -> impl IntoResponse {
4590 let raw_path = query
4591 .path
4592 .unwrap_or_else(|| "testing/fixtures/basic".to_string());
4593 let resolved = resolve_input_path(&raw_path);
4594
4595 if state.server_mode && is_sample_path(&resolved) && !resolved.exists() {
4599 return Html(
4600 r#"<div class="preview-error">Sample directory not available on this server.
4601 Enter a path to a project directory or upload files using Browse.</div>"#
4602 .to_string(),
4603 );
4604 }
4605
4606 if state.server_mode {
4607 if let Err(resp) = authorize_preview_path(&state, &resolved) {
4608 return resp;
4609 }
4610 }
4611
4612 let include_patterns = split_patterns(query.include_globs.as_deref());
4613 let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
4614
4615 match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
4616 Ok(html) => Html(html),
4617 Err(err) => Html(format!(
4618 r#"<div class="preview-error">Preview failed: {}</div>"#,
4619 escape_html(&err.to_string())
4620 )),
4621 }
4622}
4623
4624#[derive(Debug, Deserialize, Default)]
4625struct SuggestCoverageQuery {
4626 path: Option<String>,
4627}
4628
4629#[derive(Serialize)]
4630struct SuggestCoverageResponse {
4631 found: Option<String>,
4632 tool: Option<&'static str>,
4633 hint: Option<&'static str>,
4634}
4635
4636async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
4637 const CANDIDATES: &[&str] = &[
4638 "coverage/lcov.info",
4640 "lcov.info",
4641 "target/llvm-cov/lcov.info",
4642 "target/coverage/lcov.info",
4643 "target/debug/coverage/lcov.info",
4644 "coverage/coverage.lcov",
4645 "build/coverage/lcov.info",
4646 "reports/lcov.info",
4647 "coverage.xml",
4649 "coverage/coverage.xml",
4650 "target/site/cobertura/coverage.xml",
4651 "build/reports/coverage/coverage.xml",
4652 "target/site/jacoco/jacoco.xml",
4654 "build/reports/jacoco/test/jacocoTestReport.xml",
4655 "build/reports/jacoco/jacocoTestReport.xml",
4656 "build/jacoco/jacoco.xml",
4657 "coverage.json",
4659 "coverage/coverage.json",
4660 ];
4661 let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
4662 let found = CANDIDATES
4663 .iter()
4664 .map(|rel| root.join(rel))
4665 .find(|p| p.is_file())
4666 .map(|p| display_path(&p));
4667
4668 let (tool, hint) = detect_coverage_tool(&root);
4669 Json(SuggestCoverageResponse { found, tool, hint })
4670}
4671
4672fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
4675 if root.join("Cargo.toml").is_file() {
4676 return (
4677 Some("cargo-llvm-cov"),
4678 Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
4679 );
4680 }
4681 if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
4682 return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
4683 }
4684 if root.join("pom.xml").is_file() {
4685 return (Some("jacoco"), Some("mvn test jacoco:report"));
4686 }
4687 if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
4688 return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
4689 }
4690 (None, None)
4691}
4692
4693#[allow(clippy::result_large_err)]
4695fn validate_server_scan_path(
4696 config: &sloc_config::AppConfig,
4697 resolved_path: &Path,
4698 csp_nonce: &str,
4699) -> Result<(), Response> {
4700 if config.discovery.allowed_scan_roots.is_empty() {
4701 let template = ErrorTemplate {
4702 message: "Scan path rejected: no allowed_scan_roots configured on this server. \
4703 Set allowed_scan_roots in the server config to permit scanning."
4704 .to_string(),
4705 last_report_url: None,
4706 last_report_label: None,
4707 run_id: None,
4708 error_code: Some(403),
4709 csp_nonce: csp_nonce.to_owned(),
4710 version: env!("CARGO_PKG_VERSION"),
4711 };
4712 return Err((
4713 StatusCode::FORBIDDEN,
4714 Html(
4715 template
4716 .render()
4717 .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
4718 ),
4719 )
4720 .into_response());
4721 }
4722 let Ok(canonical) = fs::canonicalize(resolved_path) else {
4727 tracing::warn!(event = "path_rejected", path = %resolved_path.display(),
4728 "Scan path does not resolve to a real location");
4729 let template = ErrorTemplate {
4730 message: "The requested path could not be resolved to a real directory.".to_string(),
4731 last_report_url: None,
4732 last_report_label: None,
4733 run_id: None,
4734 error_code: Some(403),
4735 csp_nonce: csp_nonce.to_owned(),
4736 version: env!("CARGO_PKG_VERSION"),
4737 };
4738 return Err((
4739 StatusCode::FORBIDDEN,
4740 Html(
4741 template
4742 .render()
4743 .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
4744 ),
4745 )
4746 .into_response());
4747 };
4748 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
4749 fs::canonicalize(root)
4750 .ok()
4751 .is_some_and(|r| canonical.starts_with(&r))
4752 });
4753 if !allowed {
4754 tracing::warn!(event = "path_rejected", path = %canonical.display(),
4755 "Scan path not in allowed_scan_roots");
4756 let template = ErrorTemplate {
4757 message: "The requested path is not within an allowed scan directory.".to_string(),
4758 last_report_url: None,
4759 last_report_label: None,
4760 run_id: None,
4761 error_code: Some(403),
4762 csp_nonce: csp_nonce.to_owned(),
4763 version: env!("CARGO_PKG_VERSION"),
4764 };
4765 return Err((
4766 StatusCode::FORBIDDEN,
4767 Html(
4768 template
4769 .render()
4770 .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
4771 ),
4772 )
4773 .into_response());
4774 }
4775 Ok(())
4776}
4777
4778fn apply_output_dir_exclusions(
4780 config: &mut sloc_config::AppConfig,
4781 project_path: &str,
4782 raw_output_dir: &str,
4783) {
4784 let project_root = resolve_input_path(project_path);
4785 let raw_out = raw_output_dir.trim();
4786 let resolved_out = if raw_out.is_empty() {
4787 project_root.join("sloc")
4788 } else if Path::new(raw_out).is_absolute() {
4789 PathBuf::from(raw_out)
4790 } else {
4791 workspace_root().join(raw_out)
4792 };
4793 if let Ok(rel) = resolved_out.strip_prefix(&project_root) {
4794 if let Some(first) = rel.iter().next().and_then(|c| c.to_str()) {
4795 let dir = first.to_string();
4796 if !config.discovery.excluded_directories.contains(&dir) {
4797 config.discovery.excluded_directories.push(dir);
4798 }
4799 }
4800 }
4801 if !config
4802 .discovery
4803 .excluded_directories
4804 .iter()
4805 .any(|d| d == "sloc")
4806 {
4807 config
4808 .discovery
4809 .excluded_directories
4810 .push("sloc".to_string());
4811 }
4812}
4813
4814const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
4816 ScanSummarySnapshot {
4817 files_analyzed: run.summary_totals.files_analyzed,
4818 files_skipped: run.summary_totals.files_skipped,
4819 total_physical_lines: run.summary_totals.total_physical_lines,
4820 code_lines: run.summary_totals.code_lines,
4821 comment_lines: run.summary_totals.comment_lines,
4822 blank_lines: run.summary_totals.blank_lines,
4823 functions: run.summary_totals.functions,
4824 classes: run.summary_totals.classes,
4825 variables: run.summary_totals.variables,
4826 imports: run.summary_totals.imports,
4827 test_count: run.summary_totals.test_count,
4828 coverage_lines_found: run.summary_totals.coverage_lines_found,
4829 coverage_lines_hit: run.summary_totals.coverage_lines_hit,
4830 coverage_functions_found: run.summary_totals.coverage_functions_found,
4831 coverage_functions_hit: run.summary_totals.coverage_functions_hit,
4832 coverage_branches_found: run.summary_totals.coverage_branches_found,
4833 coverage_branches_hit: run.summary_totals.coverage_branches_hit,
4834 }
4835}
4836
4837pub(crate) fn build_run_registry_entry(
4839 run: &AnalysisRun,
4840 run_id: &str,
4841 project_label: &str,
4842 artifacts: &RunArtifacts,
4843) -> RegistryEntry {
4844 RegistryEntry {
4845 run_id: run_id.to_owned(),
4846 timestamp_utc: run.tool.timestamp_utc,
4847 project_label: project_label.to_owned(),
4848 input_roots: run.input_roots.clone(),
4849 json_path: artifacts.json_path.clone(),
4850 html_path: artifacts.html_path.clone(),
4851 pdf_path: artifacts.pdf_path.clone(),
4852 csv_path: artifacts.csv_path.clone(),
4853 xlsx_path: artifacts.xlsx_path.clone(),
4854 summary: summary_snapshot_from_run(run),
4855 git_branch: run.git_branch.clone(),
4856 git_commit: run.git_commit_short.clone(),
4857 git_commit_long: run.git_commit_long.clone(),
4858 git_author: run.git_commit_author.clone(),
4859 git_tags: run.git_tags.clone(),
4860 git_nearest_tag: run.git_nearest_tag.clone(),
4861 git_commit_date: run.git_commit_date.clone(),
4862 }
4863}
4864
4865fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4867 if let Some(policy) = form.mixed_line_policy {
4868 config.analysis.mixed_line_policy = policy;
4869 }
4870 config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
4871 config.analysis.generated_file_detection =
4872 form.generated_file_detection.as_deref() != Some("disabled");
4873 config.analysis.minified_file_detection =
4874 form.minified_file_detection.as_deref() != Some("disabled");
4875 config.analysis.vendor_directory_detection =
4876 form.vendor_directory_detection.as_deref() != Some("disabled");
4877 config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
4878 if let Some(binary_behavior) = form.binary_file_behavior {
4879 config.analysis.binary_file_behavior = binary_behavior;
4880 }
4881 apply_report_opts(config, form);
4882 config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
4883 config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
4884 config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
4885 if let Some(policy) = form.continuation_line_policy {
4886 config.analysis.continuation_line_policy = policy;
4887 }
4888 if let Some(policy) = form.blank_in_block_comment_policy {
4889 config.analysis.blank_in_block_comment_policy = policy;
4890 }
4891 config.analysis.count_compiler_directives =
4892 form.count_compiler_directives.as_deref() != Some("disabled");
4893 apply_style_threshold(config, form);
4894 apply_coverage_path(config, form);
4895}
4896
4897fn apply_report_opts(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4898 if let Some(report_title) = form.report_title.as_deref() {
4899 let trimmed = report_title.trim();
4900 if !trimmed.is_empty() {
4901 config.reporting.report_title = trimmed.to_string();
4902 }
4903 }
4904 if let Some(hf) = form.report_header_footer.as_deref() {
4905 let trimmed = hf.trim();
4906 config.reporting.report_header_footer = if trimmed.is_empty() {
4907 None
4908 } else {
4909 Some(trimmed.to_string())
4910 };
4911 }
4912}
4913
4914fn apply_style_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4915 apply_style_col_threshold(config, form);
4916 apply_style_analysis_enabled(config, form);
4917 apply_style_score_threshold(config, form);
4918 apply_style_lang_scope(config, form);
4919 apply_activity_window(config, form);
4920}
4921
4922fn apply_style_col_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4923 if let Some(threshold_str) = form.style_col_threshold.as_deref() {
4924 if let Ok(t) = threshold_str.parse::<u16>() {
4925 if t == 80 || t == 100 || t == 120 {
4926 config.analysis.style_col_threshold = t;
4927 }
4928 }
4929 }
4930}
4931
4932fn apply_style_analysis_enabled(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4933 if let Some(v) = form.style_analysis_enabled.as_deref() {
4934 config.analysis.style_analysis_enabled = v != "disabled";
4935 }
4936}
4937
4938fn apply_style_score_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4939 if let Some(v) = form.style_score_threshold.as_deref() {
4940 if let Ok(t) = v.parse::<u8>() {
4941 config.analysis.style_score_threshold = t.min(100);
4942 }
4943 }
4944}
4945
4946fn apply_style_lang_scope(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4947 if let Some(v) = form.style_lang_scope.as_deref() {
4948 let scope = v.trim();
4949 if scope == "c_family" || scope == "all" {
4950 config.analysis.style_lang_scope = scope.to_string();
4951 }
4952 }
4953}
4954
4955fn apply_activity_window(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4956 if let Some(w) = form.activity_window.as_deref() {
4959 let w = w.trim();
4960 if !w.is_empty() {
4961 if let Ok(days) = w.parse::<u32>() {
4962 config.analysis.activity_window_days = Some(days);
4963 }
4964 }
4965 }
4966}
4967
4968fn apply_coverage_path(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4969 if let Some(cov) = &form.coverage_file {
4970 let trimmed = cov.trim();
4971 if !trimmed.is_empty() {
4972 config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
4973 }
4974 }
4975}
4976
4977fn spawn_pdf_background(
4981 pending_pdf: PendingPdf,
4982 run_id: String,
4983 artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
4984) {
4985 if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
4986 tokio::spawn(async move {
4987 let result = tokio::task::spawn_blocking(move || {
4988 let r = write_pdf_from_html(&pdf_src, &pdf_dst);
4989 if cleanup_src {
4990 let _ = fs::remove_file(&pdf_src);
4991 }
4992 r
4993 })
4994 .await;
4995 let failed = match result {
4996 Ok(Ok(())) => false,
4997 Ok(Err(err)) => {
4998 eprintln!("[oxide-sloc][pdf] background PDF failed: {err}");
4999 true
5000 }
5001 Err(err) => {
5002 eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}");
5003 true
5004 }
5005 };
5006 if failed {
5007 let mut map = artifacts.lock().await;
5008 if let Some(entry) = map.get_mut(&run_id) {
5009 entry.pdf_path = None;
5010 }
5011 }
5012 });
5013 }
5014}
5015
5016fn spawn_native_pdf_background(
5020 json_path: PathBuf,
5021 pdf_dest: PathBuf,
5022 run_id: String,
5023 artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
5024) {
5025 tokio::spawn(async move {
5026 let result = tokio::task::spawn_blocking(move || {
5027 let run = sloc_core::read_json(&json_path)?;
5028 write_pdf_from_run(&run, &pdf_dest)
5029 })
5030 .await;
5031 let failed = match result {
5032 Ok(Ok(())) => false,
5033 Ok(Err(err)) => {
5034 eprintln!("[oxide-sloc][pdf] on-demand PDF failed: {err}");
5035 true
5036 }
5037 Err(err) => {
5038 eprintln!("[oxide-sloc][pdf] on-demand PDF task panicked: {err}");
5039 true
5040 }
5041 };
5042 if failed {
5043 let mut map = artifacts.lock().await;
5044 if let Some(entry) = map.get_mut(&run_id) {
5045 entry.pdf_path = None;
5046 }
5047 }
5048 });
5049}
5050
5051fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5053 cmp.file_deltas
5054 .iter()
5055 .map(|f| match f.status {
5056 FileChangeStatus::Added => f.current_code,
5057 FileChangeStatus::Modified => f.code_delta.max(0),
5058 _ => 0,
5059 })
5060 .sum()
5061}
5062
5063fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5065 cmp.file_deltas
5066 .iter()
5067 .map(|f| match f.status {
5068 FileChangeStatus::Removed => f.baseline_code,
5069 FileChangeStatus::Modified => (-f.code_delta).max(0),
5070 _ => 0,
5071 })
5072 .sum()
5073}
5074
5075fn sum_unmodified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5077 cmp.file_deltas
5078 .iter()
5079 .filter(|f| f.status == FileChangeStatus::Unchanged)
5080 .map(|f| f.current_code)
5081 .sum()
5082}
5083
5084fn sum_modified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5086 cmp.file_deltas
5087 .iter()
5088 .filter(|f| f.status == FileChangeStatus::Modified)
5089 .map(|f| f.current_code)
5090 .sum()
5091}
5092
5093fn build_submodule_row(
5095 s: &sloc_core::SubmoduleSummary,
5096 run: &AnalysisRun,
5097 run_id: &str,
5098 run_dir: &Path,
5099) -> SubmoduleRow {
5100 let safe = sanitize_project_label(&s.name);
5101 let artifact_key = format!("sub_{safe}");
5102 let pdf_artifact_key = format!("sub_{safe}_pdf");
5103 let html_url = if run.effective_configuration.discovery.submodule_breakdown {
5104 let parent_path = run
5105 .input_roots
5106 .first()
5107 .map_or("", std::string::String::as_str);
5108 let sub_run = build_sub_run(run, s, parent_path);
5109 let pdf_server_url = format!("/runs/{pdf_artifact_key}/{run_id}");
5110 render_sub_report_html(&sub_run, Some(&pdf_server_url))
5111 .ok()
5112 .and_then(|sub_html| {
5113 let sub_dir = run_dir.join("submodules");
5114 let _ = fs::create_dir_all(&sub_dir);
5115 let html_path = sub_dir.join(format!("{artifact_key}.html"));
5116 if fs::write(&html_path, sub_html.as_bytes()).is_ok() {
5117 let pdf_path = sub_dir.join(format!("{artifact_key}.pdf"));
5120 let _ = write_pdf_from_run(&sub_run, &pdf_path);
5121 Some(format!("/runs/{artifact_key}/{run_id}"))
5122 } else {
5123 None
5124 }
5125 })
5126 } else {
5127 None
5128 };
5129 SubmoduleRow {
5130 name: s.name.clone(),
5131 relative_path: s.relative_path.clone(),
5132 files_analyzed: s.files_analyzed,
5133 code_lines: s.code_lines,
5134 comment_lines: s.comment_lines,
5135 blank_lines: s.blank_lines,
5136 total_physical_lines: s.total_physical_lines,
5137 html_url,
5138 }
5139}
5140
5141#[allow(clippy::similar_names)]
5144#[allow(clippy::significant_drop_tightening)] #[allow(clippy::too_many_lines)]
5146async fn analyze_handler(
5147 State(state): State<AppState>,
5148 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5149 Form(form): Form<AnalyzeForm>,
5150) -> impl IntoResponse {
5151 let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
5152 let template = ErrorTemplate {
5153 message: format!(
5154 "Server is busy — all {MAX_CONCURRENT_ANALYSES} analysis slots are in use. \
5155 Please wait a moment and try again."
5156 ),
5157 last_report_url: None,
5158 last_report_label: None,
5159 run_id: None,
5160 error_code: Some(503),
5161 csp_nonce: csp_nonce.clone(),
5162 version: env!("CARGO_PKG_VERSION"),
5163 };
5164 return (
5165 StatusCode::SERVICE_UNAVAILABLE,
5166 Html(
5167 template
5168 .render()
5169 .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
5170 ),
5171 )
5172 .into_response();
5173 };
5174
5175 let mut config = state.base_config.clone();
5176
5177 let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
5178 let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
5179 let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
5180
5181 if !is_git_mode {
5182 let resolved_path = resolve_input_path(&form.path);
5183 if state.server_mode
5184 && !is_upload_tmp_path(&resolved_path)
5185 && !is_sample_path(&resolved_path)
5186 {
5187 if let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce) {
5188 return resp;
5189 }
5190 }
5191 config.discovery.root_paths = vec![resolved_path];
5192 }
5193
5194 apply_form_to_config(&mut config, &form);
5195 apply_output_dir_exclusions(
5196 &mut config,
5197 &form.path,
5198 form.output_dir.as_deref().unwrap_or(""),
5199 );
5200
5201 let wait_id = uuid::Uuid::new_v4().to_string();
5203 let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
5204
5205 let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
5207 let task_cancel = Arc::clone(&cancel_token);
5208
5209 let phase = Arc::new(std::sync::Mutex::new("Starting".to_string()));
5211 let task_phase = Arc::clone(&phase);
5212
5213 let files_done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5214 let files_total = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5215 let task_files_done = Arc::clone(&files_done);
5216 let task_files_total = Arc::clone(&files_total);
5217
5218 {
5221 let mut runs = state.async_runs.lock().await;
5222 runs.insert(
5223 wait_id.clone(),
5224 AsyncRunState::Running {
5225 started_at: std::time::Instant::now(),
5226 cancel_token,
5227 phase,
5228 files_done,
5229 files_total,
5230 },
5231 );
5232 }
5233
5234 let task = AnalysisTask {
5235 sem_permit,
5236 state: state.clone(),
5237 wait_id: wait_id.clone(),
5238 config,
5239 cancel: task_cancel,
5240 phase: task_phase,
5241 files_done: task_files_done,
5242 files_total: task_files_total,
5243 git_repo: form.git_repo.clone().filter(|s| !s.is_empty()),
5244 git_ref: form.git_ref.clone().filter(|s| !s.is_empty()),
5245 project_path: form.path.clone(),
5246 output_dir: if state.server_mode {
5250 None
5251 } else {
5252 form.output_dir.clone()
5253 },
5254 clones_dir: state.git_clones_dir.clone(),
5255 cocomo_mode: form
5256 .cocomo_mode
5257 .clone()
5258 .unwrap_or_else(|| "organic".to_string()),
5259 complexity_alert: form
5260 .complexity_alert
5261 .as_deref()
5262 .and_then(|s| s.parse::<u32>().ok())
5263 .unwrap_or(0),
5264 exclude_duplicates: form.exclude_duplicates.as_deref() == Some("enabled"),
5265 };
5266
5267 tokio::spawn(run_analysis_task(task));
5268
5269 let template = ScanWaitTemplate {
5270 version: env!("CARGO_PKG_VERSION"),
5271 wait_id_json,
5272 project_path: form.path.clone(),
5273 csp_nonce,
5274 };
5275 let html = template
5276 .render()
5277 .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
5278 let mut response = Html(html).into_response();
5279 if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id") {
5280 if let Ok(val) = axum::http::HeaderValue::from_str(&wait_id) {
5281 response.headers_mut().insert(name, val);
5282 }
5283 }
5284 response
5285}
5286
5287struct AnalysisTask {
5288 sem_permit: tokio::sync::OwnedSemaphorePermit,
5289 state: AppState,
5290 wait_id: String,
5291 config: AppConfig,
5292 cancel: Arc<std::sync::atomic::AtomicBool>,
5293 phase: Arc<std::sync::Mutex<String>>,
5294 files_done: Arc<std::sync::atomic::AtomicUsize>,
5295 files_total: Arc<std::sync::atomic::AtomicUsize>,
5296 git_repo: Option<String>,
5297 git_ref: Option<String>,
5298 project_path: String,
5299 output_dir: Option<String>,
5300 clones_dir: PathBuf,
5301 cocomo_mode: String,
5302 complexity_alert: u32,
5303 exclude_duplicates: bool,
5304}
5305
5306#[allow(clippy::too_many_lines)] async fn run_analysis_task(task: AnalysisTask) {
5308 let _permit = task.sem_permit;
5309
5310 let cancel_sb = Arc::clone(&task.cancel);
5311 let (git_repo_sb, git_ref_sb) = (task.git_repo.clone(), task.git_ref.clone());
5312 let clones_dir_sb = task.clones_dir;
5313 let upload_staging_root = task
5315 .config
5316 .discovery
5317 .root_paths
5318 .first()
5319 .filter(|p| is_upload_tmp_path(p))
5320 .and_then(|p| p.parent().filter(|par| is_upload_tmp_path(par)))
5321 .map(PathBuf::from);
5322 let config_sb = task.config;
5323 let progress_sb = sloc_core::ProgressCounters {
5324 files_done: Arc::clone(&task.files_done),
5325 files_total: Arc::clone(&task.files_total),
5326 };
5327 if let Ok(mut p) = task.phase.lock() {
5328 *p = "Scanning files".to_string();
5329 }
5330 let analysis_result = tokio::task::spawn_blocking(move || {
5331 run_analysis_blocking(
5332 config_sb,
5333 git_repo_sb,
5334 git_ref_sb,
5335 clones_dir_sb,
5336 cancel_sb,
5337 Some(progress_sb),
5338 )
5339 })
5340 .await
5341 .map_err(|err| anyhow::anyhow!(err.to_string()))
5342 .and_then(|result| result);
5343
5344 if let Ok(mut p) = task.phase.lock() {
5345 *p = "Writing reports".to_string();
5346 }
5347
5348 if task.cancel.load(std::sync::atomic::Ordering::Relaxed) {
5350 let mut runs = task.state.async_runs.lock().await;
5351 if matches!(
5353 runs.get(&task.wait_id),
5354 Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
5355 ) {
5356 runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
5357 }
5358 drop(runs);
5359 return;
5360 }
5361
5362 let run = match analysis_result {
5363 Ok(v) => v,
5364 Err(err) => {
5365 if err.to_string().contains("analysis cancelled") {
5367 let mut runs = task.state.async_runs.lock().await;
5368 runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
5369 drop(runs);
5370 return;
5371 }
5372 eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
5373 let mut runs = task.state.async_runs.lock().await;
5374 runs.insert(
5375 task.wait_id.clone(),
5376 AsyncRunState::Failed {
5377 message: "Analysis failed. Check that the path exists and is readable."
5378 .to_string(),
5379 },
5380 );
5381 drop(runs);
5382 return;
5383 }
5384 };
5385
5386 let run_id = run.tool.run_id.clone();
5387 tracing::info!(event = "scan_complete", run_id = %run_id,
5388 path = %task.project_path, files = run.summary_totals.files_analyzed,
5389 "Analysis finished");
5390
5391 let prev_entry: Option<RegistryEntry> = {
5392 let reg = task.state.registry.lock().await;
5393 reg.entries_for_roots(&run.input_roots)
5394 .into_iter()
5395 .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
5396 .cloned()
5397 };
5398
5399 let scan_delta = prev_entry.as_ref().and_then(|prev| {
5400 prev.json_path
5401 .as_ref()
5402 .and_then(|p| read_json(p).ok())
5403 .map(|prev_run| compute_delta(&prev_run, &run))
5404 });
5405 let prev_scan_count: usize = {
5406 let reg = task.state.registry.lock().await;
5407 reg.entries_for_roots(&run.input_roots)
5408 .iter()
5409 .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
5410 .count()
5411 };
5412
5413 let report_delta_ctx: Option<ReportDeltaContext> = scan_delta
5416 .as_ref()
5417 .zip(prev_entry.as_ref())
5418 .map(|(cmp, prev)| ReportDeltaContext {
5419 delta_code_added: sum_added_code_lines(cmp),
5420 delta_code_removed: sum_removed_code_lines(cmp),
5421 delta_unmodified_lines: sum_unmodified_code_lines(cmp),
5422 delta_files_added: cmp.files_added,
5423 delta_files_removed: cmp.files_removed,
5424 delta_files_modified: cmp.files_modified,
5425 delta_files_unchanged: cmp.files_unchanged,
5426 prev_code_lines: prev.summary.code_lines,
5427 prev_scan_count: prev_scan_count + 1,
5428 prev_scan_label: fmt_la_time(prev.timestamp_utc),
5429 prev_run_id: Some(prev.run_id.clone()),
5430 current_run_id: Some(run_id.clone()),
5431 });
5432 let report_html = match render_html_with_delta(&run, report_delta_ctx.as_ref()) {
5433 Ok(h) => h,
5434 Err(err) => {
5435 eprintln!("[oxide-sloc][analyze] HTML render failed: {err:#}");
5436 let mut runs = task.state.async_runs.lock().await;
5437 runs.insert(
5438 task.wait_id.clone(),
5439 AsyncRunState::Failed {
5440 message: "Failed to render HTML report.".to_string(),
5441 },
5442 );
5443 drop(runs);
5444 return;
5445 }
5446 };
5447
5448 let output_root = resolve_output_root(task.output_dir.as_deref());
5449 let project_label = derive_project_label(
5450 task.git_repo.as_deref(),
5451 task.git_ref.as_deref(),
5452 &task.project_path,
5453 );
5454 let run_dir = output_root.join(format!("{project_label}_{run_id}"));
5455 let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
5456
5457 let result_context = RunResultContext {
5458 prev_entry: prev_entry.clone(),
5459 prev_scan_count,
5460 project_path: task.project_path.clone(),
5461 cocomo_mode: task.cocomo_mode.clone(),
5462 complexity_alert: task.complexity_alert,
5463 exclude_duplicates: task.exclude_duplicates,
5464 };
5465
5466 let artifact_result = persist_run_artifacts(
5467 &run,
5468 &report_html,
5469 &run_dir,
5470 &run.effective_configuration.reporting.report_title,
5471 &file_stem,
5472 result_context,
5473 );
5474
5475 let (artifacts, pending_pdf) = match artifact_result {
5476 Ok(v) => v,
5477 Err(err) => {
5478 eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
5479 let mut runs = task.state.async_runs.lock().await;
5480 runs.insert(
5481 task.wait_id.clone(),
5482 AsyncRunState::Failed {
5483 message: "Failed to save report artifacts. Check available disk space."
5484 .to_string(),
5485 },
5486 );
5487 drop(runs);
5488 return;
5489 }
5490 };
5491
5492 {
5493 let mut map = task.state.artifacts.lock().await;
5494 map.insert(run_id.clone(), artifacts.clone());
5495 }
5496
5497 {
5498 let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
5499 let mut reg = task.state.registry.lock().await;
5500 reg.add_entry(entry);
5501 let _ = reg.save(&task.state.registry_path);
5502 }
5503
5504 if let Some(ref cfg_path) = artifacts.scan_config_path {
5505 save_scan_config_json(
5506 cfg_path,
5507 &run,
5508 &task.project_path,
5509 task.output_dir.as_deref(),
5510 &task.cocomo_mode,
5511 task.complexity_alert,
5512 task.exclude_duplicates,
5513 );
5514 }
5515
5516 spawn_pdf_background(pending_pdf, run_id.clone(), task.state.artifacts.clone());
5517
5518 prom_runs_total().inc();
5519
5520 let mut runs = task.state.async_runs.lock().await;
5522 runs.insert(
5523 task.wait_id.clone(),
5524 AsyncRunState::Complete {
5525 run_id: run_id.clone(),
5526 },
5527 );
5528 drop(runs);
5529
5530 if let Some(staging) = upload_staging_root {
5533 let _ = tokio::fs::remove_dir_all(staging).await;
5534 }
5535
5536 let _ = scan_delta;
5537}
5538
5539fn save_scan_config_json(
5540 cfg_path: &std::path::Path,
5541 run: &sloc_core::AnalysisRun,
5542 project_path: &str,
5543 output_dir: Option<&str>,
5544 cocomo_mode: &str,
5545 complexity_alert: u32,
5546 exclude_duplicates: bool,
5547) {
5548 let policy_str = serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
5549 .ok()
5550 .and_then(|v| v.as_str().map(String::from))
5551 .unwrap_or_else(|| "code_only".to_string());
5552 let behavior_str =
5553 serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
5554 .ok()
5555 .and_then(|v| v.as_str().map(String::from))
5556 .unwrap_or_else(|| "skip".to_string());
5557 let continuation_policy_str = serde_json::to_value(
5558 run.effective_configuration
5559 .analysis
5560 .continuation_line_policy,
5561 )
5562 .ok()
5563 .and_then(|v| v.as_str().map(String::from))
5564 .unwrap_or_else(default_each_physical_line);
5565 let blank_policy_str = serde_json::to_value(
5566 run.effective_configuration
5567 .analysis
5568 .blank_in_block_comment_policy,
5569 )
5570 .ok()
5571 .and_then(|v| v.as_str().map(String::from))
5572 .unwrap_or_else(default_count_as_comment);
5573 let scan_cfg = ScanConfig {
5574 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
5575 path: project_path.to_string(),
5576 include_globs: run
5577 .effective_configuration
5578 .discovery
5579 .include_globs
5580 .join("\n"),
5581 exclude_globs: run
5582 .effective_configuration
5583 .discovery
5584 .exclude_globs
5585 .join("\n"),
5586 submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
5587 mixed_line_policy: policy_str,
5588 python_docstrings_as_comments: run
5589 .effective_configuration
5590 .analysis
5591 .python_docstrings_as_comments,
5592 generated_file_detection: run
5593 .effective_configuration
5594 .analysis
5595 .generated_file_detection,
5596 minified_file_detection: run.effective_configuration.analysis.minified_file_detection,
5597 vendor_directory_detection: run
5598 .effective_configuration
5599 .analysis
5600 .vendor_directory_detection,
5601 include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
5602 binary_file_behavior: behavior_str,
5603 output_dir: output_dir.unwrap_or("").to_string(),
5604 report_title: run.effective_configuration.reporting.report_title.clone(),
5605 continuation_line_policy: continuation_policy_str,
5606 blank_in_block_comment_policy: blank_policy_str,
5607 count_compiler_directives: run
5608 .effective_configuration
5609 .analysis
5610 .count_compiler_directives,
5611 style_analysis_enabled: run.effective_configuration.analysis.style_analysis_enabled,
5612 style_col_threshold: run.effective_configuration.analysis.style_col_threshold,
5613 style_score_threshold: run.effective_configuration.analysis.style_score_threshold,
5614 style_lang_scope: run
5615 .effective_configuration
5616 .analysis
5617 .style_lang_scope
5618 .clone(),
5619 coverage_file: run
5620 .effective_configuration
5621 .analysis
5622 .coverage_file
5623 .as_ref()
5624 .map(|p| p.display().to_string())
5625 .unwrap_or_default(),
5626 cocomo_mode: cocomo_mode.to_string(),
5627 complexity_alert,
5628 exclude_duplicates,
5629 activity_window: run
5630 .effective_configuration
5631 .analysis
5632 .activity_window_days
5633 .unwrap_or(0),
5634 };
5635 if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
5636 let _ = std::fs::write(cfg_path, json);
5637 }
5638}
5639
5640#[allow(clippy::needless_pass_by_value)] fn run_analysis_blocking(
5642 mut config: AppConfig,
5643 git_repo: Option<String>,
5644 git_ref: Option<String>,
5645 clones_dir: PathBuf,
5646 cancel: Arc<std::sync::atomic::AtomicBool>,
5647 progress: Option<sloc_core::ProgressCounters>,
5648) -> Result<sloc_core::AnalysisRun> {
5649 if let (Some(repo), Some(refname)) = (git_repo, git_ref) {
5650 let dest = git_clone_dest(&repo, &clones_dir);
5651 sloc_git::clone_or_fetch(&repo, &dest)?;
5652 let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
5653 sloc_git::create_worktree(&dest, &refname, &wt)?;
5654 config.discovery.root_paths = vec![wt.clone()];
5655 let run = analyze(&config, "serve", Some(&cancel), progress.as_ref());
5656 let _ = sloc_git::destroy_worktree(&dest, &wt);
5657 let mut run = run?;
5658 if run.git_branch.is_none() {
5659 run.git_branch = Some(refname);
5660 }
5661 return Ok(run);
5662 }
5663 analyze(&config, "serve", Some(&cancel), progress.as_ref())
5664}
5665
5666fn derive_project_label(
5667 git_repo: Option<&str>,
5668 git_ref: Option<&str>,
5669 fallback_path: &str,
5670) -> String {
5671 match (
5672 git_repo.filter(|s| !s.is_empty()),
5673 git_ref.filter(|s| !s.is_empty()),
5674 ) {
5675 (Some(repo), Some(refname)) => {
5676 let repo_name = repo
5677 .trim_end_matches('/')
5678 .trim_end_matches(".git")
5679 .rsplit('/')
5680 .next()
5681 .unwrap_or("repo");
5682 sanitize_project_label(&format!("{repo_name}_{refname}"))
5683 }
5684 _ => sanitize_project_label(fallback_path),
5685 }
5686}
5687
5688fn derive_file_stem(project_label: &str, commit_short: Option<&str>) -> String {
5689 let commit = commit_short.unwrap_or("").trim();
5690 if commit.is_empty() {
5691 project_label.to_string()
5692 } else {
5693 format!("{project_label}_{commit}")
5694 }
5695}
5696
5697#[derive(Serialize)]
5700#[serde(tag = "state", rename_all = "snake_case")]
5701enum AsyncRunStatusResponse {
5702 Running {
5703 elapsed_secs: u64,
5704 phase: String,
5705 files_done: u64,
5706 files_total: u64,
5707 },
5708 Complete {
5709 run_id: String,
5710 },
5711 Failed {
5712 message: String,
5713 },
5714 Cancelled,
5715}
5716
5717async fn async_run_status_handler(
5718 State(state): State<AppState>,
5719 AxumPath(wait_id): AxumPath<String>,
5720) -> Response {
5721 if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
5723 return error::bad_request("invalid wait_id");
5724 }
5725 let run_state = {
5726 let runs = state.async_runs.lock().await;
5727 runs.get(&wait_id).cloned()
5728 };
5729 match run_state {
5730 None => error::not_found("run not found"),
5731 Some(AsyncRunState::Running {
5732 started_at,
5733 phase,
5734 files_done,
5735 files_total,
5736 ..
5737 }) => {
5738 if started_at.elapsed() > std::time::Duration::from_hours(2) {
5740 let mut runs = state.async_runs.lock().await;
5741 runs.insert(
5742 wait_id,
5743 AsyncRunState::Failed {
5744 message: "Analysis timed out after 2 hours.".to_string(),
5745 },
5746 );
5747 drop(runs);
5748 return Json(AsyncRunStatusResponse::Failed {
5749 message: "Analysis timed out after 2 hours.".to_string(),
5750 })
5751 .into_response();
5752 }
5753 let phase_str = phase.lock().map(|g| g.clone()).unwrap_or_default();
5754 Json(AsyncRunStatusResponse::Running {
5755 elapsed_secs: started_at.elapsed().as_secs(),
5756 phase: phase_str,
5757 files_done: files_done.load(std::sync::atomic::Ordering::Relaxed) as u64,
5758 files_total: files_total.load(std::sync::atomic::Ordering::Relaxed) as u64,
5759 })
5760 .into_response()
5761 }
5762 Some(AsyncRunState::Complete { run_id }) => {
5763 Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
5764 }
5765 Some(AsyncRunState::Failed { message }) => {
5766 Json(AsyncRunStatusResponse::Failed { message }).into_response()
5767 }
5768 Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
5769 }
5770}
5771
5772async fn cancel_run_handler(
5773 State(state): State<AppState>,
5774 AxumPath(wait_id): AxumPath<String>,
5775) -> Response {
5776 if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
5777 return error::bad_request("invalid wait_id");
5778 }
5779 let mut runs = state.async_runs.lock().await;
5780 let resp = match runs.get(&wait_id) {
5781 Some(AsyncRunState::Running { cancel_token, .. }) => {
5782 cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
5783 runs.insert(wait_id, AsyncRunState::Cancelled);
5784 StatusCode::OK.into_response()
5785 }
5786 Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
5787 _ => error::not_found("run not found"),
5788 };
5789 drop(runs);
5790 resp
5791}
5792
5793async fn async_run_result_handler(
5794 State(state): State<AppState>,
5795 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5796 AxumPath(run_id): AxumPath<String>,
5797) -> Response {
5798 if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
5799 return StatusCode::BAD_REQUEST.into_response();
5800 }
5801
5802 let artifacts = {
5803 let map = state.artifacts.lock().await;
5804 map.get(&run_id).cloned()
5805 };
5806 let artifacts = if let Some(a) = artifacts {
5807 a
5808 } else {
5809 let reg = state.registry.lock().await;
5810 if let Some(entry) = reg.find_by_run_id(&run_id) {
5811 recover_artifacts_from_registry(entry)
5812 } else {
5813 let html = ErrorTemplate {
5814 message: format!(
5815 "Report not found. Run ID {} is not in the scan history.",
5816 &run_id[..run_id.len().min(8)]
5817 ),
5818 last_report_url: Some("/view-reports".to_string()),
5819 last_report_label: Some("View Reports".to_string()),
5820 run_id: Some(run_id.clone()),
5821 error_code: Some(404),
5822 csp_nonce: csp_nonce.clone(),
5823 version: env!("CARGO_PKG_VERSION"),
5824 }
5825 .render()
5826 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
5827 return (StatusCode::NOT_FOUND, Html(html)).into_response();
5828 }
5829 };
5830
5831 let json_path = if let Some(p) = &artifacts.json_path {
5832 p.clone()
5833 } else {
5834 let html = ErrorTemplate {
5835 message: "JSON result was not saved for this run.".to_string(),
5836 last_report_url: Some("/view-reports".to_string()),
5837 last_report_label: Some("View Reports".to_string()),
5838 run_id: Some(run_id.clone()),
5839 error_code: Some(404),
5840 csp_nonce: csp_nonce.clone(),
5841 version: env!("CARGO_PKG_VERSION"),
5842 }
5843 .render()
5844 .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
5845 return (StatusCode::NOT_FOUND, Html(html)).into_response();
5846 };
5847
5848 let Ok(run) = read_json(&json_path) else {
5849 let folder_hint = output_folder_hint(&json_path);
5850 let redirect_url = format!("/runs/result/{run_id}");
5851 return missing_scan_relocate_response(
5852 &format!(
5853 "Scan file could not be read:\n {}\n\nThe file may have been moved or \
5854 deleted. Browse to the folder containing your scan output to reconnect it.",
5855 json_path.display()
5856 ),
5857 &run_id,
5858 &folder_hint,
5859 &redirect_url,
5860 state.server_mode,
5861 &csp_nonce,
5862 );
5863 };
5864
5865 let confluence_configured = {
5866 let store = state.confluence.lock().await;
5867 store.is_configured()
5868 };
5869
5870 render_result_page(
5871 &run,
5872 &artifacts,
5873 &run_id,
5874 &csp_nonce,
5875 confluence_configured,
5876 state.server_mode,
5877 )
5878}
5879
5880fn json_escape(s: &str) -> String {
5882 s.replace('\\', "\\\\").replace('"', "\\\"")
5883}
5884
5885struct LangTotals {
5887 physical_lines: u64,
5888 code_lines: u64,
5889 comment_lines: u64,
5890 blank_lines: u64,
5891 mixed_lines: u64,
5892 functions: u64,
5893 classes: u64,
5894 variables: u64,
5895 imports: u64,
5896}
5897
5898fn sum_lang_totals(run: &AnalysisRun) -> LangTotals {
5899 let s = |f: fn(&sloc_core::LanguageSummary) -> u64| -> u64 {
5900 run.totals_by_language.iter().map(f).sum()
5901 };
5902 LangTotals {
5903 physical_lines: s(|r| r.total_physical_lines),
5904 code_lines: s(|r| r.code_lines),
5905 comment_lines: s(|r| r.comment_lines),
5906 blank_lines: s(|r| r.blank_lines),
5907 mixed_lines: s(|r| r.mixed_lines_separate),
5908 functions: s(|r| r.functions),
5909 classes: s(|r| r.classes),
5910 variables: s(|r| r.variables),
5911 imports: s(|r| r.imports),
5912 }
5913}
5914
5915struct DeltaFields {
5917 prev_fa_str: String,
5918 prev_fs_str: String,
5919 prev_pl_str: String,
5920 prev_cl_str: String,
5921 prev_cml_str: String,
5922 prev_bl_str: String,
5923 delta_fa_str: String,
5924 delta_fa_class: String,
5925 delta_fs_str: String,
5926 delta_fs_class: String,
5927 delta_pl_str: String,
5928 delta_pl_class: String,
5929 delta_cl_str: String,
5930 delta_cl_class: String,
5931 delta_cml_str: String,
5932 delta_cml_class: String,
5933 delta_bl_str: String,
5934 delta_bl_class: String,
5935 delta_lines_added: Option<i64>,
5936 delta_lines_removed: Option<i64>,
5937 delta_lines_net_str: String,
5938 delta_lines_net_class: String,
5939}
5940
5941#[allow(
5946 clippy::similar_names,
5947 reason = "locals mirror template-bound struct fields"
5948)]
5949fn compute_delta_fields(
5950 prev_entry: Option<&RegistryEntry>,
5951 totals: &LangTotals,
5952 files_analyzed: u64,
5953 files_skipped: u64,
5954 scan_delta: Option<&sloc_core::ScanComparison>,
5955) -> DeltaFields {
5956 let prev_sum = prev_entry.map(|e| &e.summary);
5957 let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "\u{2014}".into(), |v| v.to_string());
5958
5959 let (delta_fa_str, delta_fa_class) =
5960 summary_delta(files_analyzed, prev_sum.map(|s| s.files_analyzed));
5961 let (delta_fs_str, delta_fs_class) =
5962 summary_delta(files_skipped, prev_sum.map(|s| s.files_skipped));
5963 let (delta_pl_str, delta_pl_class) = summary_delta(
5964 totals.physical_lines,
5965 prev_sum.map(|s| s.total_physical_lines),
5966 );
5967 let (delta_cl_str, delta_cl_class) =
5968 summary_delta(totals.code_lines, prev_sum.map(|s| s.code_lines));
5969 let (delta_cml_str, delta_cml_class) =
5970 summary_delta(totals.comment_lines, prev_sum.map(|s| s.comment_lines));
5971 let (delta_bl_str, delta_bl_class) =
5972 summary_delta(totals.blank_lines, prev_sum.map(|s| s.blank_lines));
5973
5974 let delta_lines_added = scan_delta.map(sum_added_code_lines);
5975 let delta_lines_removed = scan_delta.map(sum_removed_code_lines);
5976 let (delta_lines_net_str, delta_lines_net_class) =
5977 match (delta_lines_added, delta_lines_removed) {
5978 (Some(a), Some(r)) => {
5979 let net = a - r;
5980 (fmt_delta(net), delta_class(net).to_string())
5981 }
5982 _ => ("\u{2014}".to_string(), "na".to_string()),
5983 };
5984
5985 DeltaFields {
5986 prev_fa_str: fmt_prev(prev_sum.map(|s| s.files_analyzed)),
5987 prev_fs_str: fmt_prev(prev_sum.map(|s| s.files_skipped)),
5988 prev_pl_str: fmt_prev(prev_sum.map(|s| s.total_physical_lines)),
5989 prev_cl_str: fmt_prev(prev_sum.map(|s| s.code_lines)),
5990 prev_cml_str: fmt_prev(prev_sum.map(|s| s.comment_lines)),
5991 prev_bl_str: fmt_prev(prev_sum.map(|s| s.blank_lines)),
5992 delta_fa_str,
5993 delta_fa_class: delta_fa_class.to_string(),
5994 delta_fs_str,
5995 delta_fs_class: delta_fs_class.to_string(),
5996 delta_pl_str,
5997 delta_pl_class: delta_pl_class.to_string(),
5998 delta_cl_str,
5999 delta_cl_class: delta_cl_class.to_string(),
6000 delta_cml_str,
6001 delta_cml_class: delta_cml_class.to_string(),
6002 delta_bl_str,
6003 delta_bl_class: delta_bl_class.to_string(),
6004 delta_lines_added,
6005 delta_lines_removed,
6006 delta_lines_net_str,
6007 delta_lines_net_class,
6008 }
6009}
6010
6011fn delta_unmodified_lines(scan_delta: &sloc_core::ScanComparison) -> u64 {
6013 scan_delta
6014 .file_deltas
6015 .iter()
6016 .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
6017 .map(|f| {
6018 #[allow(clippy::cast_sign_loss)]
6019 let n = f.current_code as u64;
6020 n
6021 })
6022 .sum()
6023}
6024
6025fn git_commit_url_for(run: &AnalysisRun) -> Option<String> {
6026 run.git_remote_url
6027 .as_deref()
6028 .zip(run.git_commit_long.as_deref())
6029 .and_then(|(remote, sha)| remote_to_commit_url(remote, sha))
6030}
6031
6032fn git_branch_url_for(run: &AnalysisRun) -> Option<String> {
6033 run.git_remote_url
6034 .as_deref()
6035 .zip(run.git_branch.as_deref())
6036 .and_then(|(remote, branch)| remote_to_branch_url(remote, branch))
6037}
6038
6039fn scan_performed_by(run: &AnalysisRun) -> String {
6040 run.environment.ci_name.clone().unwrap_or_else(|| {
6041 format!(
6042 "{} / {}",
6043 run.environment.initiator_username, run.environment.initiator_hostname
6044 )
6045 })
6046}
6047
6048fn build_lang_chart_json(run: &AnalysisRun) -> String {
6050 let mut langs: Vec<&sloc_core::LanguageSummary> = run.totals_by_language.iter().collect();
6051 langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
6052 let entries: Vec<String> = langs
6053 .into_iter()
6054 .take(12)
6055 .map(|l| {
6056 let name = json_escape(l.language.display_name());
6057 format!(
6058 r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
6059 name,
6060 l.code_lines,
6061 l.comment_lines,
6062 l.blank_lines,
6063 l.total_physical_lines,
6064 l.functions,
6065 l.classes,
6066 l.variables,
6067 l.imports,
6068 l.files,
6069 )
6070 })
6071 .collect();
6072 format!("[{}]", entries.join(","))
6073}
6074
6075fn build_scatter_chart_json(run: &AnalysisRun) -> String {
6077 let entries: Vec<String> = run
6078 .totals_by_language
6079 .iter()
6080 .map(|l| {
6081 let name = json_escape(l.language.display_name());
6082 format!(
6083 r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
6084 name, l.files, l.code_lines, l.total_physical_lines,
6085 )
6086 })
6087 .collect();
6088 format!("[{}]", entries.join(","))
6089}
6090
6091fn build_semantic_chart_json(run: &AnalysisRun) -> String {
6093 let entries: Vec<String> = run
6094 .totals_by_language
6095 .iter()
6096 .filter(|l| {
6097 l.functions > 0 || l.classes > 0 || l.variables > 0 || l.imports > 0 || l.test_count > 0
6098 })
6099 .map(|l| {
6100 let name = json_escape(l.language.display_name());
6101 format!(
6102 r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{},"tests":{}}}"#,
6103 name, l.functions, l.classes, l.variables, l.imports, l.test_count,
6104 )
6105 })
6106 .collect();
6107 format!("[{}]", entries.join(","))
6108}
6109
6110fn build_submodule_chart_json(run: &AnalysisRun) -> String {
6112 let entries: Vec<String> = run
6113 .submodule_summaries
6114 .iter()
6115 .map(|s| {
6116 let name = json_escape(&s.name);
6117 format!(
6118 r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
6119 name,
6120 s.code_lines,
6121 s.comment_lines,
6122 s.blank_lines,
6123 s.total_physical_lines,
6124 s.files_analyzed,
6125 )
6126 })
6127 .collect();
6128 format!("[{}]", entries.join(","))
6129}
6130
6131#[allow(clippy::cast_precision_loss)]
6133fn cov_pct_str(hit: u64, found: u64) -> String {
6134 if found > 0 {
6135 format!("{:.1}", hit as f64 / found as f64 * 100.0)
6136 } else {
6137 String::new()
6138 }
6139}
6140
6141fn cov_lines_summary_str(hit: u64, found: u64) -> String {
6143 if found > 0 {
6144 format!("{hit} / {found}")
6145 } else {
6146 String::new()
6147 }
6148}
6149
6150const fn cocomo_coefficients(mode: sloc_core::CocomoMode) -> (f64, f64, f64, f64) {
6151 use sloc_core::CocomoMode;
6152 match mode {
6153 CocomoMode::SemiDetached => (3.0, 1.12, 2.5, 0.35),
6154 CocomoMode::Embedded => (3.6, 1.20, 2.5, 0.32),
6155 CocomoMode::Organic => (2.4, 1.05, 2.5, 0.38),
6156 }
6157}
6158
6159const fn cocomo_mode_label(mode: sloc_core::CocomoMode) -> &'static str {
6160 use sloc_core::CocomoMode;
6161 match mode {
6162 CocomoMode::Organic => "Organic",
6163 CocomoMode::SemiDetached => "Semi-detached",
6164 CocomoMode::Embedded => "Embedded",
6165 }
6166}
6167
6168const fn cocomo_mode_tooltip(mode: sloc_core::CocomoMode) -> &'static str {
6169 use sloc_core::CocomoMode;
6170 match mode {
6171 CocomoMode::Organic => {
6172 "Organic: A small team working on a well-understood project in a familiar \
6173 environment with minimal external constraints. Suited for internal tools, \
6174 utilities, and projects with stable requirements. Effort = 2.4 \u{00D7} KSLOC^1.05."
6175 }
6176 CocomoMode::SemiDetached => {
6177 "Semi-detached: A mixed team with varying experience tackling a project with \
6178 moderate novelty and some rigid constraints. Typical for compilers, transaction \
6179 systems, and batch processors. Effort = 3.0 \u{00D7} KSLOC^1.12."
6180 }
6181 CocomoMode::Embedded => {
6182 "Embedded: Tight hardware, software, or operational constraints requiring \
6183 significant innovation and deep integration work. Typical for real-time control \
6184 systems and safety-critical software. Effort = 3.6 \u{00D7} KSLOC^1.20."
6185 }
6186 }
6187}
6188
6189struct CocomoFields {
6191 has_cocomo: bool,
6192 effort_str: String,
6193 duration_str: String,
6194 staff_str: String,
6195 ksloc_str: String,
6196 mode_label: String,
6197 mode_tooltip: String,
6198}
6199
6200#[allow(clippy::cast_precision_loss)]
6201fn recompute_cocomo(run: &AnalysisRun, mode_str: &str) -> CocomoFields {
6202 use sloc_core::CocomoMode;
6203 let mode = match mode_str {
6204 "semi_detached" => CocomoMode::SemiDetached,
6205 "embedded" => CocomoMode::Embedded,
6206 _ => CocomoMode::Organic,
6207 };
6208 let (a, b, c, d) = cocomo_coefficients(mode);
6209 let ksloc = run.summary_totals.code_lines as f64 / 1_000.0;
6210 let effort = a * ksloc.powf(b);
6211 let duration = c * effort.powf(d);
6212 let staff = if duration > 0.0 {
6213 effort / duration
6214 } else {
6215 0.0
6216 };
6217 let round2 = |x: f64| format!("{:.2}", (x * 100.0).round() / 100.0);
6218 let mode_label = cocomo_mode_label(mode).to_string();
6219 let mode_tooltip = cocomo_mode_tooltip(mode).to_string();
6220 if run.summary_totals.code_lines > 0 {
6221 CocomoFields {
6222 has_cocomo: true,
6223 effort_str: round2(effort),
6224 duration_str: round2(duration),
6225 staff_str: round2(staff),
6226 ksloc_str: round2(ksloc),
6227 mode_label,
6228 mode_tooltip,
6229 }
6230 } else {
6231 CocomoFields {
6232 has_cocomo: false,
6233 effort_str: String::new(),
6234 duration_str: String::new(),
6235 staff_str: String::new(),
6236 ksloc_str: String::new(),
6237 mode_label,
6238 mode_tooltip,
6239 }
6240 }
6241}
6242
6243#[allow(clippy::too_many_lines)]
6244#[allow(clippy::similar_names)] #[allow(clippy::cast_precision_loss)] fn render_result_page(
6247 run: &AnalysisRun,
6248 artifacts: &RunArtifacts,
6249 run_id: &str,
6250 csp_nonce: &str,
6251 confluence_configured: bool,
6252 server_mode: bool,
6253) -> Response {
6254 let ctx = &artifacts.result_context;
6255 let prev_entry = &ctx.prev_entry;
6256 let prev_scan_count = ctx.prev_scan_count;
6257 let project_path_owned = if ctx.project_path.is_empty() {
6261 run.input_roots.join(", ")
6262 } else {
6263 ctx.project_path.clone()
6264 };
6265 let project_path = &project_path_owned;
6266
6267 let scan_delta = prev_entry.as_ref().and_then(|prev| {
6268 prev.json_path
6269 .as_ref()
6270 .and_then(|p| read_json(p).ok())
6271 .map(|prev_run| compute_delta(&prev_run, run))
6272 });
6273
6274 let files_analyzed = run.per_file_records.len() as u64;
6275 let files_skipped = run.skipped_file_records.len() as u64;
6276 let totals = sum_lang_totals(run);
6277
6278 let DeltaFields {
6279 prev_fa_str,
6280 prev_fs_str,
6281 prev_pl_str,
6282 prev_cl_str,
6283 prev_cml_str,
6284 prev_bl_str,
6285 delta_fa_str,
6286 delta_fa_class,
6287 delta_fs_str,
6288 delta_fs_class,
6289 delta_pl_str,
6290 delta_pl_class,
6291 delta_cl_str,
6292 delta_cl_class,
6293 delta_cml_str,
6294 delta_cml_class,
6295 delta_bl_str,
6296 delta_bl_class,
6297 delta_lines_added,
6298 delta_lines_removed,
6299 delta_lines_net_str,
6300 delta_lines_net_class,
6301 } = compute_delta_fields(
6302 prev_entry.as_ref(),
6303 &totals,
6304 files_analyzed,
6305 files_skipped,
6306 scan_delta.as_ref(),
6307 );
6308
6309 let run_dir = artifacts.output_dir.clone();
6310 let git_branch = run.git_branch.clone();
6311 let git_commit = run.git_commit_short.clone();
6312 let git_commit_long = run.git_commit_long.clone();
6313 let git_author = run.git_commit_author.clone();
6314 let git_commit_url = git_commit_url_for(run);
6315 let git_branch_url = git_branch_url_for(run);
6316 let scan_performed_by = scan_performed_by(run);
6317 let scan_time_display = fmt_la_time_meta(run.tool.timestamp_utc);
6318 let os_display = format!(
6319 "{} / {}",
6320 run.environment.operating_system, run.environment.architecture
6321 );
6322 let test_count = run.summary_totals.test_count;
6323
6324 let cyclomatic_complexity = run.summary_totals.cyclomatic_complexity;
6326 let lsloc = run.summary_totals.lsloc;
6327 let uloc = run.uloc;
6328 let dryness_pct_str = run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}"));
6329 let duplicate_group_count = run.duplicate_groups.len();
6330
6331 let ctx = &artifacts.result_context;
6333 let CocomoFields {
6334 has_cocomo,
6335 effort_str: cocomo_effort_str,
6336 duration_str: cocomo_duration_str,
6337 staff_str: cocomo_staff_str,
6338 ksloc_str: cocomo_ksloc_str,
6339 mode_label: cocomo_mode_label,
6340 mode_tooltip: cocomo_mode_tooltip,
6341 } = recompute_cocomo(run, ctx.cocomo_mode.as_str());
6342 let complexity_alert = ctx.complexity_alert;
6343
6344 let template = ResultTemplate {
6345 version: env!("CARGO_PKG_VERSION"),
6346 report_title: run.effective_configuration.reporting.report_title.clone(),
6347 project_path: project_path.clone(),
6348 output_dir: display_path(&artifacts.output_dir),
6349 run_id: run_id.to_owned(),
6350 run_id_short: run_id
6351 .split('-')
6352 .next_back()
6353 .unwrap_or(run_id)
6354 .chars()
6355 .take(7)
6356 .collect(),
6357 files_analyzed,
6358 files_skipped,
6359 physical_lines: totals.physical_lines,
6360 code_lines: totals.code_lines,
6361 comment_lines: totals.comment_lines,
6362 blank_lines: totals.blank_lines,
6363 mixed_lines: totals.mixed_lines,
6364 functions: totals.functions,
6365 classes: totals.classes,
6366 variables: totals.variables,
6367 imports: totals.imports,
6368 html_url: artifacts
6369 .html_path
6370 .as_ref()
6371 .map(|_| format!("/runs/html/{run_id}")),
6372 pdf_url: artifacts
6373 .pdf_path
6374 .as_ref()
6375 .map(|_| format!("/runs/pdf/{run_id}")),
6376 json_url: artifacts
6377 .json_path
6378 .as_ref()
6379 .map(|_| format!("/runs/json/{run_id}")),
6380 html_download_url: artifacts
6381 .html_path
6382 .as_ref()
6383 .map(|_| format!("/runs/html/{run_id}?download=1")),
6384 pdf_download_url: artifacts
6385 .pdf_path
6386 .as_ref()
6387 .map(|_| format!("/runs/pdf/{run_id}?download=1")),
6388 json_download_url: artifacts
6389 .json_path
6390 .as_ref()
6391 .map(|_| format!("/runs/json/{run_id}?download=1")),
6392 html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
6393 json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
6394 prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
6395 prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
6396 prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
6397 prev_fa_str,
6398 prev_fs_str,
6399 prev_pl_str,
6400 prev_cl_str,
6401 prev_cml_str,
6402 prev_bl_str,
6403 delta_fa_str,
6404 delta_fa_class,
6405 delta_fs_str,
6406 delta_fs_class,
6407 delta_pl_str,
6408 delta_pl_class,
6409 delta_cl_str,
6410 delta_cl_class,
6411 delta_cml_str,
6412 delta_cml_class,
6413 delta_bl_str,
6414 delta_bl_class,
6415 delta_lines_added,
6416 delta_lines_removed,
6417 delta_lines_net_str,
6418 delta_lines_net_class,
6419 delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
6420 delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
6421 delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
6422 delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
6423 delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
6424 git_branch,
6425 git_branch_url,
6426 git_commit,
6427 git_commit_long,
6428 git_author,
6429 git_commit_url,
6430 scan_performed_by,
6431 scan_time_display,
6432 scan_time_utc_ms: run.tool.timestamp_utc.timestamp_millis(),
6433 os_display,
6434 test_count,
6435 test_assertion_count: run.summary_totals.test_assertion_count,
6436 current_scan_number: prev_scan_count + 1,
6437 prev_scan_count,
6438 submodule_rows: run
6439 .submodule_summaries
6440 .iter()
6441 .map(|s| build_submodule_row(s, run, run_id, &run_dir))
6442 .collect(),
6443 pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
6444 scan_config_url: format!("/runs/scan-config/{run_id}"),
6445 lang_chart_json: build_lang_chart_json(run),
6446 scatter_chart_json: build_scatter_chart_json(run),
6447 semantic_chart_json: build_semantic_chart_json(run),
6448 submodule_chart_json: build_submodule_chart_json(run),
6449 has_submodule_data: !run.submodule_summaries.is_empty(),
6450 has_semantic_data: run
6451 .totals_by_language
6452 .iter()
6453 .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
6454 csp_nonce: csp_nonce.to_owned(),
6455 confluence_configured,
6456 server_mode,
6457 report_header_footer: run
6458 .effective_configuration
6459 .reporting
6460 .report_header_footer
6461 .clone(),
6462 is_offline: false,
6463 cyclomatic_complexity,
6464 lsloc,
6465 uloc,
6466 dryness_pct_str,
6467 duplicate_group_count,
6468 has_cocomo,
6469 cocomo_effort_str,
6470 cocomo_duration_str,
6471 cocomo_staff_str,
6472 cocomo_ksloc_str,
6473 cocomo_mode_label,
6474 cocomo_mode_tooltip,
6475 complexity_alert,
6476 has_coverage_data: run.summary_totals.coverage_lines_found > 0,
6477 cov_line_pct: cov_pct_str(
6478 run.summary_totals.coverage_lines_hit,
6479 run.summary_totals.coverage_lines_found,
6480 ),
6481 cov_fn_pct: cov_pct_str(
6482 run.summary_totals.coverage_functions_hit,
6483 run.summary_totals.coverage_functions_found,
6484 ),
6485 cov_branch_pct: cov_pct_str(
6486 run.summary_totals.coverage_branches_hit,
6487 run.summary_totals.coverage_branches_found,
6488 ),
6489 cov_lines_summary: cov_lines_summary_str(
6490 run.summary_totals.coverage_lines_hit,
6491 run.summary_totals.coverage_lines_found,
6492 ),
6493 };
6494
6495 Html(
6496 template
6497 .render()
6498 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
6499 )
6500 .into_response()
6501}
6502
6503fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
6504 let slug: String = report_title
6505 .chars()
6506 .map(|c| {
6507 if c.is_alphanumeric() || c == '-' {
6508 c.to_ascii_lowercase()
6509 } else {
6510 '_'
6511 }
6512 })
6513 .collect::<String>()
6514 .split('_')
6515 .filter(|s| !s.is_empty())
6516 .collect::<Vec<_>>()
6517 .join("_");
6518
6519 let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
6520
6521 if slug.is_empty() {
6522 format!("report_{short_id}.pdf")
6523 } else {
6524 format!("{slug}_{short_id}.pdf")
6525 }
6526}
6527
6528#[derive(Serialize)]
6529struct PdfStatusResponse {
6530 ready: bool,
6531}
6532
6533async fn pdf_status_handler(
6536 State(state): State<AppState>,
6537 AxumPath(run_id): AxumPath<String>,
6538) -> Response {
6539 let pdf_path = {
6540 let registry = state.artifacts.lock().await;
6541 registry.get(&run_id).and_then(|a| a.pdf_path.clone())
6542 };
6543 let pdf_path = if pdf_path.is_some() {
6544 pdf_path
6545 } else {
6546 let reg = state.registry.lock().await;
6547 reg.find_by_run_id(&run_id)
6548 .map(recover_artifacts_from_registry)
6549 .and_then(|a| a.pdf_path)
6550 };
6551 let ready = pdf_path.is_some_and(|p| p.exists());
6552 Json(PdfStatusResponse { ready }).into_response()
6553}
6554
6555async fn download_bundle_handler(
6561 State(state): State<AppState>,
6562 AxumPath(run_id): AxumPath<String>,
6563) -> Response {
6564 let output_dir = {
6566 let cache = state.artifacts.lock().await;
6567 cache.get(&run_id).map(|a| a.output_dir.clone())
6568 };
6569 let output_dir = if let Some(d) = output_dir {
6570 d
6571 } else {
6572 let reg = state.registry.lock().await;
6573 match reg.find_by_run_id(&run_id) {
6574 Some(entry) => recover_artifacts_from_registry(entry).output_dir,
6575 None => {
6576 return (
6577 StatusCode::NOT_FOUND,
6578 Json(serde_json::json!({"error": "Run not found"})),
6579 )
6580 .into_response();
6581 }
6582 }
6583 };
6584
6585 if !output_dir.exists() {
6586 return (
6587 StatusCode::NOT_FOUND,
6588 Json(serde_json::json!({"error": "Output directory no longer exists on disk"})),
6589 )
6590 .into_response();
6591 }
6592
6593 let run_id_clone = run_id.clone();
6595 let archive_result = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
6596 use flate2::{write::GzEncoder, Compression};
6597 let mut enc = GzEncoder::new(Vec::new(), Compression::default());
6598 {
6599 let mut tar = tar::Builder::new(&mut enc);
6600 tar.follow_symlinks(false);
6601 if let Ok(entries) = std::fs::read_dir(&output_dir) {
6604 for entry in entries.filter_map(Result::ok) {
6605 let p = entry.path();
6606 if p.is_file() {
6607 let name = p.file_name().unwrap_or_default().to_string_lossy();
6608 let archive_path = format!("{run_id_clone}/{name}");
6609 tar.append_path_with_name(&p, &archive_path)?;
6610 }
6611 }
6612 }
6613 tar.finish()?;
6614 }
6615 Ok(enc.finish()?)
6616 })
6617 .await;
6618
6619 match archive_result {
6620 Ok(Ok(bytes)) => {
6621 let filename = format!("oxide-sloc-{}.tar.gz", &run_id[..run_id.len().min(8)]);
6622 axum::response::Response::builder()
6623 .status(StatusCode::OK)
6624 .header("Content-Type", "application/gzip")
6625 .header(
6626 "Content-Disposition",
6627 format!("attachment; filename=\"{filename}\""),
6628 )
6629 .header("Content-Length", bytes.len().to_string())
6630 .body(axum::body::Body::from(bytes))
6631 .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
6632 }
6633 Ok(Err(e)) => (
6634 StatusCode::INTERNAL_SERVER_ERROR,
6635 Json(serde_json::json!({"error": format!("Archive build failed: {e}")})),
6636 )
6637 .into_response(),
6638 Err(e) => (
6639 StatusCode::INTERNAL_SERVER_ERROR,
6640 Json(serde_json::json!({"error": format!("Task panicked: {e}")})),
6641 )
6642 .into_response(),
6643 }
6644}
6645
6646async fn delete_run_handler(
6651 State(state): State<AppState>,
6652 AxumPath(run_id): AxumPath<String>,
6653) -> Response {
6654 let output_dir = {
6656 let mut cache = state.artifacts.lock().await;
6657 let dir = cache.get(&run_id).map(|a| a.output_dir.clone());
6658 cache.remove(&run_id);
6659 dir
6660 };
6661 let output_dir = if let Some(d) = output_dir {
6662 d
6663 } else {
6664 let reg = state.registry.lock().await;
6665 reg.find_by_run_id(&run_id)
6666 .map(|e| recover_artifacts_from_registry(e).output_dir)
6667 .unwrap_or_default()
6668 };
6669
6670 {
6672 let mut reg = state.registry.lock().await;
6673 reg.entries.retain(|e| e.run_id != run_id);
6674 let _ = reg.save(&state.registry_path);
6675 }
6676
6677 if output_dir.exists() {
6680 match tokio::fs::remove_dir_all(&output_dir).await {
6681 Ok(()) => {}
6682 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
6683 Err(e) => {
6684 return (
6685 StatusCode::INTERNAL_SERVER_ERROR,
6686 Json(serde_json::json!({"error": format!("Failed to delete files: {e}")})),
6687 )
6688 .into_response();
6689 }
6690 }
6691 }
6692
6693 StatusCode::NO_CONTENT.into_response()
6694}
6695
6696async fn cleanup_runs_handler(
6701 State(state): State<AppState>,
6702 Json(body): Json<serde_json::Value>,
6703) -> Response {
6704 let days = body
6705 .get("older_than_days")
6706 .and_then(serde_json::Value::as_u64)
6707 .unwrap_or(30)
6708 .max(1);
6709
6710 let cutoff = chrono::Utc::now() - chrono::Duration::days(days.cast_signed());
6711
6712 let expired: Vec<(String, PathBuf)> = {
6714 let reg = state.registry.lock().await;
6715 reg.entries
6716 .iter()
6717 .filter(|e| e.timestamp_utc < cutoff)
6718 .map(|e| {
6719 let arts = recover_artifacts_from_registry(e);
6720 (e.run_id.clone(), arts.output_dir)
6721 })
6722 .collect()
6723 };
6724
6725 let mut deleted = 0usize;
6726 for (run_id, output_dir) in &expired {
6727 state.artifacts.lock().await.remove(run_id);
6729 if output_dir.exists() {
6731 if let Err(e) = tokio::fs::remove_dir_all(output_dir).await {
6732 eprintln!(
6733 "[oxide-sloc] cleanup: failed to remove {}: {e:#}",
6734 output_dir.display()
6735 );
6736 continue;
6737 }
6738 }
6739 deleted += 1;
6740 }
6741
6742 let expired_ids: std::collections::HashSet<&str> =
6744 expired.iter().map(|(id, _)| id.as_str()).collect();
6745 {
6746 let mut reg = state.registry.lock().await;
6747 reg.entries
6748 .retain(|e| !expired_ids.contains(e.run_id.as_str()));
6749 let _ = reg.save(&state.registry_path);
6750 }
6751
6752 Json(serde_json::json!({ "deleted": deleted })).into_response()
6753}
6754
6755fn spawn_cleanup_policy_task(state: AppState) -> tokio::task::JoinHandle<()> {
6758 tokio::spawn(async move {
6759 loop {
6760 let interval_secs = {
6761 let store = state.cleanup_policy.lock().await;
6762 match &store.policy {
6763 Some(p) if p.enabled => u64::from(p.interval_hours.max(1)) * 3600,
6764 _ => break,
6765 }
6766 };
6767 tokio::time::sleep(Duration::from_secs(interval_secs)).await;
6768 let n = run_auto_cleanup(&state).await;
6769 tracing::info!("[cleanup-policy] scheduled pass: deleted {n} runs");
6770 }
6771 })
6772}
6773
6774fn collect_runs_to_delete(
6775 reg: &ScanRegistry,
6776 max_age_days: Option<u32>,
6777 max_run_count: Option<u32>,
6778) -> std::collections::HashSet<String> {
6779 let mut to_delete = std::collections::HashSet::new();
6780 if let Some(days) = max_age_days {
6781 let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(days));
6782 for e in ®.entries {
6783 if e.timestamp_utc < cutoff {
6784 to_delete.insert(e.run_id.clone());
6785 }
6786 }
6787 }
6788 if let Some(max_count) = max_run_count {
6789 for e in reg.entries.iter().skip(max_count as usize) {
6791 to_delete.insert(e.run_id.clone());
6792 }
6793 }
6794 to_delete
6795}
6796
6797async fn delete_run_artifacts(state: &AppState, run_id: &str) {
6798 let output_dir = {
6799 let mut cache = state.artifacts.lock().await;
6800 let d = cache.get(run_id).map(|a| a.output_dir.clone());
6801 cache.remove(run_id);
6802 d
6803 };
6804 let output_dir = if let Some(d) = output_dir {
6805 d
6806 } else {
6807 let reg = state.registry.lock().await;
6808 reg.find_by_run_id(run_id)
6809 .map(|e| recover_artifacts_from_registry(e).output_dir)
6810 .unwrap_or_default()
6811 };
6812 if output_dir.exists() {
6813 let _ = tokio::fs::remove_dir_all(&output_dir).await;
6814 }
6815}
6816
6817async fn run_auto_cleanup(state: &AppState) -> u32 {
6821 let (max_age_days, max_run_count) = {
6822 let store = state.cleanup_policy.lock().await;
6823 match &store.policy {
6824 Some(p) if p.enabled => (p.max_age_days, p.max_run_count),
6825 _ => return 0,
6826 }
6827 };
6828
6829 let to_delete = {
6830 let reg = state.registry.lock().await;
6831 collect_runs_to_delete(®, max_age_days, max_run_count)
6832 };
6833
6834 for run_id in &to_delete {
6835 delete_run_artifacts(state, run_id).await;
6836 }
6837
6838 if !to_delete.is_empty() {
6840 let mut reg = state.registry.lock().await;
6841 reg.entries.retain(|e| !to_delete.contains(&e.run_id));
6842 let _ = reg.save(&state.registry_path);
6843 }
6844
6845 let deleted = u32::try_from(to_delete.len()).unwrap_or(u32::MAX);
6846 {
6847 let mut store = state.cleanup_policy.lock().await;
6848 store.last_run_at = Some(chrono::Utc::now());
6849 store.last_run_deleted = Some(deleted);
6850 let _ = store.save(&state.cleanup_policy_path);
6851 }
6852 deleted
6853}
6854
6855async fn api_get_cleanup_policy(State(state): State<AppState>) -> Response {
6859 let store = state.cleanup_policy.lock().await;
6860 Json(serde_json::json!({
6861 "policy": store.policy,
6862 "last_run_at": store.last_run_at,
6863 "last_run_deleted": store.last_run_deleted,
6864 }))
6865 .into_response()
6866}
6867
6868async fn api_save_cleanup_policy(
6870 State(state): State<AppState>,
6871 Json(body): Json<CleanupPolicy>,
6872) -> Response {
6873 {
6875 let mut handle = state.cleanup_task_handle.lock().await;
6876 if let Some(h) = handle.take() {
6877 h.abort();
6878 }
6879 }
6880 {
6881 let mut store = state.cleanup_policy.lock().await;
6882 store.policy = Some(body.clone());
6883 if let Err(e) = store.save(&state.cleanup_policy_path) {
6884 return (
6885 StatusCode::INTERNAL_SERVER_ERROR,
6886 Json(serde_json::json!({"error": e.to_string()})),
6887 )
6888 .into_response();
6889 }
6890 }
6891 if body.enabled {
6892 let handle = spawn_cleanup_policy_task(state.clone());
6893 *state.cleanup_task_handle.lock().await = Some(handle);
6894 }
6895 StatusCode::NO_CONTENT.into_response()
6896}
6897
6898async fn api_run_cleanup_now(State(state): State<AppState>) -> Response {
6900 let deleted = run_auto_cleanup(&state).await;
6901 Json(serde_json::json!({ "deleted": deleted })).into_response()
6902}
6903
6904async fn api_delete_cleanup_policy(State(state): State<AppState>) -> Response {
6906 {
6907 let mut handle = state.cleanup_task_handle.lock().await;
6908 if let Some(h) = handle.take() {
6909 h.abort();
6910 }
6911 }
6912 {
6913 let mut store = state.cleanup_policy.lock().await;
6914 store.policy = None;
6915 let _ = store.save(&state.cleanup_policy_path);
6916 }
6917 StatusCode::NO_CONTENT.into_response()
6918}
6919
6920fn swap_inline_chart_js_for_static(html: String) -> String {
6926 let Some(head_end) = html.find("</head>") else {
6927 return html;
6928 };
6929 let Some(script_start) = html[..head_end].rfind("<script") else {
6930 return html;
6931 };
6932 let Some(close_offset) = html[script_start..].find("</script>") else {
6933 return html;
6934 };
6935 let block_end = script_start + close_offset + "</script>".len();
6936 format!(
6937 "{}<script src=\"/static/chart-report.js\"></script>{}",
6938 &html[..script_start],
6939 &html[block_end..]
6940 )
6941}
6942
6943fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
6945 let Some(start) = html.find("nonce=\"") else {
6947 return html
6951 .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
6952 .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
6953 };
6954 let value_start = start + 7; let Some(end_offset) = html[value_start..].find('"') else {
6956 return html.to_owned();
6957 };
6958 let old_nonce = &html[value_start..value_start + end_offset];
6959 html.replace(
6960 &format!("nonce=\"{old_nonce}\""),
6961 &format!("nonce=\"{new_nonce}\""),
6962 )
6963}
6964
6965fn serve_html_artifact(
6966 path: &Path,
6967 wants_download: bool,
6968 csp_nonce: &str,
6969 run_id: &str,
6970 server_mode: bool,
6971) -> Response {
6972 match fs::read_to_string(path) {
6973 Ok(raw) => {
6974 let content = patch_html_nonce(&raw, csp_nonce);
6976 if wants_download {
6977 (
6979 [
6980 (header::CONTENT_TYPE, "text/html; charset=utf-8"),
6981 (
6982 header::CONTENT_DISPOSITION,
6983 "attachment; filename=report.html",
6984 ),
6985 ],
6986 content,
6987 )
6988 .into_response()
6989 } else {
6990 Html(swap_inline_chart_js_for_static(content)).into_response()
6993 }
6994 }
6995 Err(err) if err.kind() == std::io::ErrorKind::NotFound && !run_id.is_empty() => {
6996 let filename = path.file_name().map_or_else(
6997 || "report.html".to_string(),
6998 |n| n.to_string_lossy().into_owned(),
6999 );
7000 let html = LocateFileTemplate {
7001 run_id: run_id.to_owned(),
7002 artifact_type: "html".to_string(),
7003 expected_filename: filename,
7004 server_mode,
7005 csp_nonce: csp_nonce.to_owned(),
7006 version: env!("CARGO_PKG_VERSION"),
7007 }
7008 .render()
7009 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7010 (StatusCode::NOT_FOUND, Html(html)).into_response()
7011 }
7012 Err(err) => {
7013 let filename = path.file_name().map_or_else(
7014 || "report.html".to_string(),
7015 |n| n.to_string_lossy().into_owned(),
7016 );
7017 let msg = format!("HTML report '{filename}' could not be read.\n\nError: {err}");
7018 let html = ErrorTemplate {
7019 message: msg,
7020 last_report_url: Some("/view-reports".to_string()),
7021 last_report_label: Some("View Reports".to_string()),
7022 run_id: None,
7023 error_code: Some(404),
7024 csp_nonce: csp_nonce.to_owned(),
7025 version: env!("CARGO_PKG_VERSION"),
7026 }
7027 .render()
7028 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7029 (StatusCode::NOT_FOUND, Html(html)).into_response()
7030 }
7031 }
7032}
7033
7034fn serve_pdf_artifact(
7036 path: &Path,
7037 report_title: &str,
7038 run_id: &str,
7039 wants_download: bool,
7040 csp_nonce: &str,
7041) -> Response {
7042 match fs::read(path) {
7043 Ok(bytes) => {
7044 let filename = build_pdf_filename(report_title, run_id);
7045 let disposition = if wants_download {
7046 format!("attachment; filename=\"{filename}\"")
7047 } else {
7048 format!("inline; filename=\"{filename}\"")
7049 };
7050 (
7051 [
7052 (header::CONTENT_TYPE, "application/pdf".to_string()),
7053 (header::CONTENT_DISPOSITION, disposition),
7054 ],
7055 bytes,
7056 )
7057 .into_response()
7058 }
7059 Err(err) => {
7060 let filename = path.file_name().map_or_else(
7061 || "report.pdf".to_string(),
7062 |n| n.to_string_lossy().into_owned(),
7063 );
7064 let msg = format!(
7065 "PDF report '{filename}' could not be read.\n\n\
7066 Error: {err}\n\n\
7067 If you moved or renamed the output folder, the stored path is now stale. \
7068 Use 'Open PDF folder' from the results page to browse the output directory."
7069 );
7070 let html = ErrorTemplate {
7071 message: msg,
7072 last_report_url: Some("/view-reports".to_string()),
7073 last_report_label: Some("View Reports".to_string()),
7074 run_id: Some(run_id.to_owned()),
7075 error_code: Some(404),
7076 csp_nonce: csp_nonce.to_owned(),
7077 version: env!("CARGO_PKG_VERSION"),
7078 }
7079 .render()
7080 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7081 (StatusCode::NOT_FOUND, Html(html)).into_response()
7082 }
7083 }
7084}
7085
7086fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
7088 match fs::read(path) {
7089 Ok(bytes) => {
7090 if wants_download {
7091 (
7092 [
7093 (header::CONTENT_TYPE, "application/json; charset=utf-8"),
7094 (
7095 header::CONTENT_DISPOSITION,
7096 "attachment; filename=result.json",
7097 ),
7098 ],
7099 bytes,
7100 )
7101 .into_response()
7102 } else {
7103 (
7104 [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
7105 bytes,
7106 )
7107 .into_response()
7108 }
7109 }
7110 Err(err) => {
7111 let filename = path.file_name().map_or_else(
7112 || "result.json".to_string(),
7113 |n| n.to_string_lossy().into_owned(),
7114 );
7115 let msg = format!(
7116 "JSON result '{filename}' could not be read.\n\n\
7117 Error: {err}\n\n\
7118 If you moved or renamed the output folder, the stored path is now stale. \
7119 Use 'Open JSON folder' from the results page to browse the output directory."
7120 );
7121 let html = ErrorTemplate {
7122 message: msg,
7123 last_report_url: Some("/view-reports".to_string()),
7124 last_report_label: Some("View Reports".to_string()),
7125 run_id: None,
7126 error_code: Some(404),
7127 csp_nonce: csp_nonce.to_owned(),
7128 version: env!("CARGO_PKG_VERSION"),
7129 }
7130 .render()
7131 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7132 (StatusCode::NOT_FOUND, Html(html)).into_response()
7133 }
7134 }
7135}
7136
7137fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
7139 let output_dir = entry
7142 .html_path
7143 .as_ref()
7144 .or(entry.json_path.as_ref())
7145 .or(entry.pdf_path.as_ref())
7146 .or(entry.csv_path.as_ref())
7147 .or(entry.xlsx_path.as_ref())
7148 .and_then(|p| {
7149 let parent = p.parent()?;
7150 let parent_name = parent.file_name().and_then(|n| n.to_str()).unwrap_or("");
7151 if matches!(parent_name, "html" | "json" | "pdf" | "excel") {
7153 parent.parent().map(PathBuf::from)
7154 } else {
7155 Some(parent.to_path_buf())
7156 }
7157 })
7158 .unwrap_or_default();
7159 let pdf_path = entry.pdf_path.clone().or_else(|| {
7162 let candidate = output_dir.join("report.pdf");
7163 candidate.exists().then_some(candidate)
7164 });
7165 let scan_dir_for = |ext: &str| -> Option<PathBuf> {
7169 for dir in &[output_dir.join("excel"), output_dir.clone()] {
7171 if let Some(p) = fs::read_dir(dir).ok().and_then(|entries| {
7172 entries
7173 .filter_map(std::result::Result::ok)
7174 .find(|e| {
7175 let n = e.file_name();
7176 let n = n.to_string_lossy();
7177 n.starts_with("report_") && n.ends_with(ext)
7178 })
7179 .map(|e| e.path())
7180 }) {
7181 return Some(p);
7182 }
7183 }
7184 None
7185 };
7186
7187 let csv_path = entry.csv_path.clone().or_else(|| scan_dir_for(".csv"));
7188 let xlsx_path = entry.xlsx_path.clone().or_else(|| scan_dir_for(".xlsx"));
7189 RunArtifacts {
7190 output_dir: output_dir.clone(),
7191 html_path: entry.html_path.clone(),
7192 pdf_path,
7193 json_path: entry.json_path.clone(),
7194 csv_path,
7195 xlsx_path,
7196 scan_config_path: find_scan_config_in_dir(&output_dir),
7197 report_title: entry.project_label.clone(),
7198 result_context: RunResultContext::default(),
7199 }
7200}
7201
7202#[allow(clippy::result_large_err)] async fn resolve_artifact_set(
7204 state: &AppState,
7205 run_id: &str,
7206 csp_nonce: &str,
7207) -> Result<RunArtifacts, Response> {
7208 let cached = state.artifacts.lock().await.get(run_id).cloned();
7209 if let Some(a) = cached {
7210 return Ok(a);
7211 }
7212 let reg = state.registry.lock().await;
7213 if let Some(entry) = reg.find_by_run_id(run_id) {
7214 return Ok(recover_artifacts_from_registry(entry));
7215 }
7216 drop(reg);
7217 let short_id = &run_id[..run_id.len().min(8)];
7218 let hint = if matches!(
7219 run_id,
7220 "pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
7221 ) {
7222 format!(
7223 " The URL format appears to be reversed \u{2014} \
7224 the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
7225 Use the View Reports page to navigate to your scan."
7226 )
7227 } else {
7228 " The report may have been deleted or the report directory moved. \
7229 Use View Reports to browse your scan history."
7230 .to_string()
7231 };
7232 let error_html = ErrorTemplate {
7233 message: format!("Report not found. \"{short_id}\" is not a recognized run ID.{hint}"),
7234 last_report_url: Some("/view-reports".to_string()),
7235 last_report_label: Some("View Reports".to_string()),
7236 run_id: None,
7237 error_code: Some(404),
7238 csp_nonce: csp_nonce.to_owned(),
7239 version: env!("CARGO_PKG_VERSION"),
7240 }
7241 .render()
7242 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
7243 Err((StatusCode::NOT_FOUND, Html(error_html)).into_response())
7244}
7245
7246async fn resolve_or_queue_pdf(
7251 state: &AppState,
7252 pdf_path: Option<PathBuf>,
7253 json_path: Option<PathBuf>,
7254 output_dir: PathBuf,
7255 run_id: &str,
7256 report_title: &str,
7257 csp_nonce: &str,
7258) -> Result<PathBuf, Response> {
7259 if let Some(p) = pdf_path {
7260 return Ok(p);
7261 }
7262 let Some(json_src) = json_path.filter(|p| p.exists()) else {
7263 let msg = "PDF report was not generated for this run. \
7264 Re-run the analysis with PDF output enabled."
7265 .to_string();
7266 let html = ErrorTemplate {
7267 message: msg,
7268 last_report_url: Some(format!("/runs/html/{run_id}")),
7269 last_report_label: Some("View HTML Report".to_string()),
7270 run_id: Some(run_id.to_string()),
7271 error_code: Some(404),
7272 csp_nonce: csp_nonce.to_string(),
7273 version: env!("CARGO_PKG_VERSION"),
7274 }
7275 .render()
7276 .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
7277 return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
7278 };
7279 let pdf_filename = build_pdf_filename(report_title, run_id);
7280 let pdf_dest = output_dir.join(&pdf_filename);
7281 if !pdf_dest.exists() {
7282 {
7284 let mut map = state.artifacts.lock().await;
7285 if let Some(entry) = map.get_mut(run_id) {
7286 entry.pdf_path = Some(pdf_dest.clone());
7287 }
7288 }
7289 {
7290 let mut reg = state.registry.lock().await;
7291 if let Some(e) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
7292 e.pdf_path = Some(pdf_dest.clone());
7293 }
7294 let _ = reg.save(&state.registry_path);
7295 }
7296 spawn_native_pdf_background(
7297 json_src,
7298 pdf_dest.clone(),
7299 run_id.to_string(),
7300 state.artifacts.clone(),
7301 );
7302 }
7303 Ok(pdf_dest)
7304}
7305
7306fn pdf_generating_response(run_id: &str, csp_nonce: &str) -> Response {
7308 let html = format!(
7309 "<!doctype html><html lang=\"en\"><head>\
7310 <meta charset=utf-8>\
7311 <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
7312 <meta http-equiv=\"refresh\" content=\"5\">\
7313 <title>OxideSLOC | Generating PDF\u{2026}</title>\
7314 <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
7315 <style nonce=\"{csp_nonce}\">\
7316 :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
7317 --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
7318 --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
7319 body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
7320 --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
7321 *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
7322 font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
7323 background:var(--bg);color:var(--text);}}\
7324 .top-nav{{position:sticky;top:0;z-index:30;\
7325 background:linear-gradient(180deg,var(--nav),var(--nav-2));\
7326 border-bottom:1px solid rgba(255,255,255,0.12);\
7327 box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
7328 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
7329 min-height:56px;display:flex;align-items:center;gap:14px;}}\
7330 .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
7331 .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
7332 filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
7333 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
7334 .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
7335 .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
7336 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
7337 .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
7338 border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
7339 background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
7340 .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
7341 .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
7342 justify-content:center;min-height:38px;border-radius:999px;\
7343 border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
7344 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
7345 .theme-toggle .icon-sun{{display:none;}}\
7346 body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
7347 body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
7348 .page{{width:100%;max-width:1720px;margin:0 auto;padding:60px 24px;\
7349 display:flex;align-items:center;justify-content:center;\
7350 min-height:calc(100vh - 56px);}}\
7351 @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}\
7352 .panel{{background:var(--surface);border:1px solid var(--line);\
7353 border-radius:var(--radius);box-shadow:var(--shadow);\
7354 padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
7355 .spin-ring{{width:56px;height:56px;border-radius:50%;\
7356 border:5px solid var(--line);border-top-color:var(--oxide-2);\
7357 animation:spin 1s linear infinite;margin:0 auto 28px;}}\
7358 @keyframes spin{{to{{transform:rotate(360deg);}}}}\
7359 h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
7360 p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
7361 .back-link{{display:inline-flex;align-items:center;justify-content:center;\
7362 min-height:42px;padding:0 20px;border-radius:14px;\
7363 border:1px solid var(--line-strong);text-decoration:none;\
7364 color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
7365 .back-link:hover{{background:var(--line);}}\
7366 </style></head>\
7367 <body>\
7368 <div class=\"top-nav\"><div class=\"top-nav-inner\">\
7369 <a class=\"brand\" href=\"/\">\
7370 <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
7371 <div class=\"brand-copy\">\
7372 <div class=\"brand-title\">OxideSLOC</div>\
7373 <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
7374 </div>\
7375 </a>\
7376 <div class=\"nav-right\">\
7377 <a class=\"nav-pill\" href=\"/\">Home</a>\
7378 <a class=\"nav-pill\" href=\"/view-reports\">View Reports</a>\
7379 <a class=\"nav-pill\" href=\"/compare-scans\">Compare Scans</a>\
7380 <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
7381 <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>\
7382 <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
7383 <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>\
7384 </button>\
7385 </div>\
7386 </div></div>\
7387 <div class=\"page\"><div class=\"panel\">\
7388 <div class=\"spin-ring\"></div>\
7389 <h1>Generating PDF\u{2026}</h1>\
7390 <p>The PDF is being generated from the scan results.<br>\
7391 This page refreshes automatically \u{2014} usually a few seconds.</p>\
7392 <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
7393 </div></div>\
7394 <script nonce=\"{csp_nonce}\">\
7395 (function(){{\
7396 var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
7397 if(s===\"dark\")b.classList.add(\"dark-theme\");\
7398 var t=document.getElementById(\"theme-toggle\");\
7399 if(t)t.addEventListener(\"click\",function(){{\
7400 var d=b.classList.toggle(\"dark-theme\");\
7401 localStorage.setItem(k,d?\"dark\":\"light\");\
7402 }});\
7403 }})();\
7404 </script>\
7405 </body></html>"
7406 );
7407 Html(html).into_response()
7408}
7409
7410fn render_error_artifact_html(
7412 message: String,
7413 last_report_url: Option<String>,
7414 last_report_label: Option<String>,
7415 run_id: Option<String>,
7416 error_code: Option<u16>,
7417 csp_nonce: &str,
7418) -> String {
7419 ErrorTemplate {
7420 message,
7421 last_report_url,
7422 last_report_label,
7423 run_id,
7424 error_code,
7425 csp_nonce: csp_nonce.to_owned(),
7426 version: env!("CARGO_PKG_VERSION"),
7427 }
7428 .render()
7429 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string())
7430}
7431
7432fn serve_binary_download(path: &Path, content_type: &str, fallback_filename: &str) -> Response {
7434 fs::read(path).map_or_else(
7435 |_| StatusCode::NOT_FOUND.into_response(),
7436 |bytes| {
7437 let filename = path.file_name().map_or_else(
7438 || fallback_filename.to_string(),
7439 |n| n.to_string_lossy().into_owned(),
7440 );
7441 (
7442 [
7443 (header::CONTENT_TYPE, content_type.to_string()),
7444 (
7445 header::CONTENT_DISPOSITION,
7446 format!("attachment; filename=\"{filename}\""),
7447 ),
7448 ],
7449 bytes,
7450 )
7451 .into_response()
7452 },
7453 )
7454}
7455
7456fn serve_csv_arm(csv_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
7457 let Some(path) = csv_path else {
7458 let html = render_error_artifact_html(
7459 "CSV report was not generated for this run, or was not recorded in \
7460 the scan registry."
7461 .to_string(),
7462 Some(format!("/runs/html/{run_id}")),
7463 Some("View HTML Report".to_string()),
7464 Some(run_id.to_string()),
7465 Some(404),
7466 csp_nonce,
7467 );
7468 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7469 };
7470 serve_binary_download(&path, "text/csv; charset=utf-8", "report.csv")
7471}
7472
7473fn serve_xlsx_arm(xlsx_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
7474 let Some(path) = xlsx_path else {
7475 let html = render_error_artifact_html(
7476 "Excel report was not generated for this run, or was not recorded in \
7477 the scan registry."
7478 .to_string(),
7479 Some(format!("/runs/html/{run_id}")),
7480 Some("View HTML Report".to_string()),
7481 Some(run_id.to_string()),
7482 Some(404),
7483 csp_nonce,
7484 );
7485 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7486 };
7487 serve_binary_download(
7488 &path,
7489 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
7490 "report.xlsx",
7491 )
7492}
7493
7494fn serve_scan_config_arm(artifact_set: &RunArtifacts) -> Response {
7495 let path = artifact_set
7496 .scan_config_path
7497 .as_deref()
7498 .map(std::path::Path::to_path_buf)
7499 .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
7500 .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
7501 fs::read(&path).map_or_else(
7502 |_| StatusCode::NOT_FOUND.into_response(),
7503 |bytes| {
7504 (
7505 [
7506 (
7507 header::CONTENT_TYPE,
7508 "application/json; charset=utf-8".to_string(),
7509 ),
7510 (
7511 header::CONTENT_DISPOSITION,
7512 "attachment; filename=\"scan-config.json\"".to_string(),
7513 ),
7514 ],
7515 bytes,
7516 )
7517 .into_response()
7518 },
7519 )
7520}
7521
7522async fn serve_submodule_pdf_arm(
7527 artifact: &str,
7528 artifact_set: RunArtifacts,
7529 wants_download: bool,
7530 run_id: &str,
7531 csp_nonce: &str,
7532) -> Response {
7533 let base = artifact.trim_end_matches("_pdf");
7535 let sub_dir = artifact_set.output_dir.join("submodules");
7536 let pdf_path = sub_dir.join(format!("{base}.pdf"));
7537
7538 if !pdf_path.exists() {
7539 let derived_safe = base.trim_start_matches("sub_");
7541 let rebuilt = artifact_set.json_path.as_deref().and_then(|jp| {
7542 let parent_run = read_json(jp).ok()?;
7543 let sub = parent_run
7544 .submodule_summaries
7545 .iter()
7546 .find(|s| sanitize_project_label(&s.name) == derived_safe)?
7547 .clone();
7548 let parent_path = parent_run.input_roots.first().cloned().unwrap_or_default();
7549 Some((parent_run, sub, parent_path))
7550 });
7551
7552 if let Some((parent_run, sub, parent_path)) = rebuilt {
7553 let sub_run = build_sub_run(&parent_run, &sub, &parent_path);
7554 let pp = pdf_path.clone();
7555 let _ = tokio::task::spawn_blocking(move || write_pdf_from_run(&sub_run, &pp)).await;
7556 }
7557 }
7558
7559 if !pdf_path.exists() {
7560 let html = render_error_artifact_html(
7561 "Sub-report PDF could not be generated — re-run the scan with submodule breakdown \
7562 enabled."
7563 .to_string(),
7564 Some("/view-reports".to_string()),
7565 Some("View Reports".to_string()),
7566 Some(run_id.to_string()),
7567 Some(404),
7568 csp_nonce,
7569 );
7570 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7571 }
7572
7573 serve_pdf_artifact(
7574 &pdf_path,
7575 &artifact_set.report_title,
7576 run_id,
7577 wants_download,
7578 csp_nonce,
7579 )
7580}
7581
7582fn serve_submodule_arm(
7583 artifact: &str,
7584 artifact_set: &RunArtifacts,
7585 wants_download: bool,
7586 csp_nonce: &str,
7587 run_id: &str,
7588 server_mode: bool,
7589) -> Response {
7590 if artifact.len() > 128
7591 || !artifact
7592 .chars()
7593 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
7594 {
7595 return StatusCode::BAD_REQUEST.into_response();
7596 }
7597 let filename = format!("{artifact}.html");
7598 let new_layout = artifact_set.output_dir.join("submodules").join(&filename);
7600 let path = if new_layout.exists() {
7601 new_layout
7602 } else {
7603 artifact_set.output_dir.join(&filename)
7604 };
7605 if !path.exists() {
7606 let html = render_error_artifact_html(
7607 format!(
7608 "Sub-report '{artifact}' was not found in the run directory.\n\
7609 Re-run the analysis with 'Detect and separate git submodules' \
7610 and HTML output enabled."
7611 ),
7612 Some("/view-reports".to_string()),
7613 Some("View Reports".to_string()),
7614 Some(run_id.to_string()),
7615 Some(404),
7616 csp_nonce,
7617 );
7618 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7619 }
7620 serve_html_artifact(&path, wants_download, csp_nonce, run_id, server_mode)
7621}
7622
7623async fn serve_pdf_arm(
7624 state: &AppState,
7625 artifact_set: RunArtifacts,
7626 wants_download: bool,
7627 run_id: &str,
7628 csp_nonce: &str,
7629) -> Response {
7630 let report_title = artifact_set.report_title.clone();
7631 let had_pdf_in_registry = artifact_set.pdf_path.is_some();
7632 let stale_html_name = artifact_set
7633 .html_path
7634 .as_deref()
7635 .and_then(|p| p.file_name())
7636 .map(|n| n.to_string_lossy().into_owned());
7637 let path = match resolve_or_queue_pdf(
7638 state,
7639 artifact_set.pdf_path,
7640 artifact_set.json_path.clone(),
7641 artifact_set.output_dir.clone(),
7642 run_id,
7643 &report_title,
7644 csp_nonce,
7645 )
7646 .await
7647 {
7648 Ok(p) => p,
7649 Err(r) => return r,
7650 };
7651 if !path.exists() {
7652 if had_pdf_in_registry {
7656 if let Some(expected_filename) = stale_html_name {
7657 let html = LocateFileTemplate {
7658 run_id: run_id.to_string(),
7659 artifact_type: "pdf".to_string(),
7660 expected_filename,
7661 server_mode: state.server_mode,
7662 csp_nonce: csp_nonce.to_string(),
7663 version: env!("CARGO_PKG_VERSION"),
7664 }
7665 .render()
7666 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7667 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7668 }
7669 }
7670 return pdf_generating_response(run_id, csp_nonce);
7671 }
7672 serve_pdf_artifact(&path, &report_title, run_id, wants_download, csp_nonce)
7673}
7674
7675async fn artifact_handler(
7676 State(state): State<AppState>,
7677 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7678 AxumPath((artifact, run_id)): AxumPath<(String, String)>,
7679 Query(query): Query<ArtifactQuery>,
7680) -> Response {
7681 let artifact_set = match resolve_artifact_set(&state, &run_id, &csp_nonce).await {
7682 Ok(a) => a,
7683 Err(r) => return r,
7684 };
7685
7686 let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
7687
7688 match artifact.as_str() {
7689 "html" => {
7690 let Some(path) = artifact_set.html_path else {
7691 return StatusCode::NOT_FOUND.into_response();
7692 };
7693 serve_html_artifact(
7694 &path,
7695 wants_download,
7696 &csp_nonce,
7697 &run_id,
7698 state.server_mode,
7699 )
7700 }
7701 "pdf" => serve_pdf_arm(&state, artifact_set, wants_download, &run_id, &csp_nonce).await,
7702 "json" => {
7703 let Some(path) = artifact_set.json_path else {
7704 let html = render_error_artifact_html(
7705 "JSON result was not generated for this run, or was not recorded in \
7706 the scan registry. Re-run the analysis with JSON output enabled."
7707 .to_string(),
7708 Some("/view-reports".to_string()),
7709 Some("View Reports".to_string()),
7710 Some(run_id.clone()),
7711 Some(404),
7712 &csp_nonce,
7713 );
7714 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7715 };
7716 serve_json_artifact(&path, wants_download, &csp_nonce)
7717 }
7718 "csv" => serve_csv_arm(artifact_set.csv_path, &run_id, &csp_nonce),
7719 "xlsx" => serve_xlsx_arm(artifact_set.xlsx_path, &run_id, &csp_nonce),
7720 "scan-config" => serve_scan_config_arm(&artifact_set),
7721 _ if artifact.starts_with("sub_") && artifact.ends_with("_pdf") => {
7722 serve_submodule_pdf_arm(&artifact, artifact_set, wants_download, &run_id, &csp_nonce)
7723 .await
7724 }
7725 _ if artifact.starts_with("sub_") => serve_submodule_arm(
7726 &artifact,
7727 &artifact_set,
7728 wants_download,
7729 &csp_nonce,
7730 &run_id,
7731 state.server_mode,
7732 ),
7733 _ => StatusCode::NOT_FOUND.into_response(),
7734 }
7735}
7736
7737struct SubmoduleLinkRow {
7740 name: String,
7741 url: String,
7742}
7743
7744struct HistoryEntryRow {
7745 run_id: String,
7746 run_id_short: String,
7747 timestamp: String,
7748 timestamp_utc_ms: i64,
7749 project_label: String,
7750 project_path: String,
7751 files_analyzed: u64,
7752 files_skipped: u64,
7753 code_lines: u64,
7754 comment_lines: u64,
7755 blank_lines: u64,
7756 total_physical_lines: u64,
7757 functions: u64,
7758 classes: u64,
7759 variables: u64,
7760 imports: u64,
7761 test_count: u64,
7762 git_branch: String,
7763 git_commit: String,
7764 git_commit_long: String,
7766 has_html: bool,
7767 has_json: bool,
7768 has_pdf: bool,
7769 submodule_links: Vec<SubmoduleLinkRow>,
7770 submodule_names_csv: String,
7772}
7773
7774fn nth_weekday_of_month(
7776 year: i32,
7777 month: u32,
7778 weekday: chrono::Weekday,
7779 n: u32,
7780) -> chrono::NaiveDate {
7781 use chrono::Datelike;
7782 let mut count = 0u32;
7783 let mut day = 1u32;
7784 loop {
7785 let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
7786 if d.weekday() == weekday {
7787 count += 1;
7788 if count == n {
7789 return d;
7790 }
7791 }
7792 day += 1;
7793 }
7794}
7795
7796fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
7800 use chrono::{Datelike, TimeZone};
7801 let year = dt.year();
7802 let dst_start = chrono::Utc.from_utc_datetime(
7803 &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
7804 .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
7805 );
7806 let dst_end = chrono::Utc.from_utc_datetime(
7807 &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
7808 .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
7809 );
7810 dt >= dst_start && dt < dst_end
7811}
7812
7813fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
7814 if is_pacific_dst(dt) {
7815 dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
7816 .format("%Y-%m-%d %H:%M PDT")
7817 .to_string()
7818 } else {
7819 dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
7820 .format("%Y-%m-%d %H:%M PST")
7821 .to_string()
7822 }
7823}
7824
7825fn fmt_la_time_meta(dt: chrono::DateTime<chrono::Utc>) -> String {
7827 let (offset, tz) = if is_pacific_dst(dt) {
7828 (
7829 chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"),
7830 "PDT",
7831 )
7832 } else {
7833 (
7834 chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"),
7835 "PST",
7836 )
7837 };
7838 format!(
7839 "{} {tz}",
7840 dt.with_timezone(&offset).format("%Y-%m-%d %H:%M:%S")
7841 )
7842}
7843
7844fn fmt_git_date(iso: &str) -> Option<String> {
7845 chrono::DateTime::parse_from_rfc3339(iso)
7846 .ok()
7847 .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
7848}
7849
7850fn extract_long_commit_from_json(path: &Path, short: &str) -> Option<String> {
7859 use std::io::{Read, Seek, SeekFrom};
7860 const TAIL: u64 = 4 * 1024 * 1024; if short.is_empty() {
7862 return None;
7863 }
7864 let len = std::fs::metadata(path).ok()?.len();
7865 let start = len.saturating_sub(TAIL);
7866 let mut file = std::fs::File::open(path).ok()?;
7867 file.seek(SeekFrom::Start(start)).ok()?;
7868 let mut buf = Vec::new();
7869 file.read_to_end(&mut buf).ok()?;
7870 let text = String::from_utf8_lossy(&buf);
7871 let short_lower = short.to_ascii_lowercase();
7872 let key = "\"git_commit_long\"";
7873 let mut found: Option<String> = None;
7874 let mut cursor = 0usize;
7875 while let Some(idx) = text[cursor..].find(key) {
7876 let after_key = cursor + idx + key.len();
7877 cursor = after_key;
7878 let rest = &text[after_key..];
7879 let Some(colon) = rest.find(':') else { break };
7880 let value_region = rest[colon + 1..].trim_start();
7881 if let Some(open) = value_region.strip_prefix('"') {
7883 if let Some(close) = open.find('"') {
7884 let val = &open[..close];
7885 if val.len() >= short.len() && val.to_ascii_lowercase().starts_with(&short_lower) {
7886 found = Some(val.to_string());
7887 }
7888 }
7889 }
7890 }
7891 found
7892}
7893
7894fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
7895 reg.entries
7896 .iter()
7897 .map(|e| {
7898 let submodule_links = {
7899 let mut links: Vec<SubmoduleLinkRow> = vec![];
7900 let sub_dir = e
7901 .html_path
7902 .as_ref()
7903 .and_then(|p| p.parent())
7904 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
7905 if let Some(dir) = sub_dir {
7906 if let Ok(rd) = std::fs::read_dir(dir) {
7907 for entry_res in rd.flatten() {
7908 let fname = entry_res.file_name();
7909 let fname_str = fname.to_string_lossy();
7910 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
7911 let stem = &fname_str[..fname_str.len() - 5];
7912 let display = stem[4..].replace('-', " ");
7913 links.push(SubmoduleLinkRow {
7914 name: display,
7915 url: format!("/runs/{stem}/{}", e.run_id),
7916 });
7917 }
7918 }
7919 }
7920 }
7921 links.sort_by(|a, b| a.name.cmp(&b.name));
7922 links
7923 };
7924 let submodule_names_csv = submodule_links
7925 .iter()
7926 .map(|l| l.name.as_str())
7927 .collect::<Vec<_>>()
7928 .join(",");
7929 HistoryEntryRow {
7930 run_id: e.run_id.clone(),
7931 run_id_short: e
7932 .run_id
7933 .split('-')
7934 .next_back()
7935 .unwrap_or(&e.run_id)
7936 .chars()
7937 .take(7)
7938 .collect(),
7939 timestamp: fmt_la_time(e.timestamp_utc),
7940 timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
7941 project_label: e.project_label.clone(),
7942 project_path: e
7943 .input_roots
7944 .first()
7945 .map(|s| sanitize_path_str(s))
7946 .unwrap_or_default(),
7947 files_analyzed: e.summary.files_analyzed,
7948 files_skipped: e.summary.files_skipped,
7949 code_lines: e.summary.code_lines,
7950 comment_lines: e.summary.comment_lines,
7951 blank_lines: e.summary.blank_lines,
7952 total_physical_lines: e.summary.total_physical_lines,
7953 functions: e.summary.functions,
7954 classes: e.summary.classes,
7955 variables: e.summary.variables,
7956 imports: e.summary.imports,
7957 test_count: e.summary.test_count,
7958 git_branch: e.git_branch.clone().unwrap_or_default(),
7959 git_commit: e.git_commit.clone().unwrap_or_default(),
7960 git_commit_long: {
7961 let short = e.git_commit.clone().unwrap_or_default();
7962 e.git_commit_long
7963 .clone()
7964 .filter(|s| !s.is_empty())
7965 .or_else(|| {
7966 e.json_path
7967 .as_ref()
7968 .and_then(|p| extract_long_commit_from_json(p, &short))
7969 })
7970 .unwrap_or(short)
7971 },
7972 has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
7973 has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
7974 has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
7975 submodule_links,
7976 submodule_names_csv,
7977 }
7978 })
7979 .collect()
7980}
7981
7982#[derive(Deserialize, Default)]
7983struct HistoryQuery {
7984 linked: Option<String>,
7985 error: Option<String>,
7986}
7987
7988async fn history_handler(
7989 State(state): State<AppState>,
7990 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7991 Query(query): Query<HistoryQuery>,
7992) -> impl IntoResponse {
7993 auto_scan_watched_dirs(&state).await;
7995 let watched_dirs: Vec<String> = {
7996 let wd = state.watched_dirs.lock().await;
7997 wd.dirs.iter().map(|p| p.display().to_string()).collect()
7998 };
7999 let mut entries = {
8000 let reg = state.registry.lock().await;
8001 make_history_rows(®)
8002 };
8003 entries.retain(|e| e.has_html);
8004 let total_scans = entries.len();
8005 let linked_count = query
8006 .linked
8007 .as_deref()
8008 .and_then(|s| s.parse::<usize>().ok())
8009 .unwrap_or(0);
8010 let browse_error = query.error.filter(|s| !s.is_empty());
8011 let template = HistoryTemplate {
8012 version: env!("CARGO_PKG_VERSION"),
8013 entries,
8014 total_scans,
8015 linked_count,
8016 browse_error,
8017 watched_dirs,
8018 csp_nonce,
8019 server_mode: state.server_mode,
8020 };
8021 Html(
8022 template
8023 .render()
8024 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
8025 )
8026 .into_response()
8027}
8028
8029async fn compare_select_handler(
8030 State(state): State<AppState>,
8031 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8032) -> impl IntoResponse {
8033 auto_scan_watched_dirs(&state).await;
8034 let watched_dirs: Vec<String> = {
8035 let wd = state.watched_dirs.lock().await;
8036 wd.dirs.iter().map(|p| p.display().to_string()).collect()
8037 };
8038 let mut entries = {
8039 let reg = state.registry.lock().await;
8040 make_history_rows(®)
8041 };
8042 entries.retain(|e| e.has_json);
8043 let total_scans = entries.len();
8044 let template = CompareSelectTemplate {
8045 version: env!("CARGO_PKG_VERSION"),
8046 entries,
8047 total_scans,
8048 watched_dirs,
8049 csp_nonce,
8050 server_mode: state.server_mode,
8051 };
8052 Html(
8053 template
8054 .render()
8055 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
8056 )
8057 .into_response()
8058}
8059
8060#[derive(Deserialize, Default)]
8063struct CompareQuery {
8064 a: Option<String>,
8065 b: Option<String>,
8066 sub: Option<String>,
8068 scope: Option<String>,
8070}
8071
8072struct CompareFileDeltaRow {
8073 relative_path: String,
8074 language: String,
8075 status: String,
8076 baseline_code: i64,
8077 current_code: i64,
8078 baseline_code_display: String,
8079 current_code_display: String,
8080 code_delta_str: String,
8081 code_delta_class: String,
8082 comment_delta_str: String,
8083 comment_delta_class: String,
8084 total_delta_str: String,
8085 total_delta_class: String,
8086}
8087
8088fn recompute_summary_from_records(run: &mut AnalysisRun) {
8091 let mut totals = SummaryTotals::default();
8092 for r in &run.per_file_records {
8093 if r.language.is_some() {
8094 totals.files_analyzed += 1;
8095 }
8096 totals.total_physical_lines += r.raw_line_categories.total_physical_lines;
8097 totals.code_lines += r.effective_counts.code_lines;
8098 totals.comment_lines += r.effective_counts.comment_lines;
8099 totals.blank_lines += r.effective_counts.blank_lines;
8100 totals.mixed_lines_separate += r.effective_counts.mixed_lines_separate;
8101 totals.functions += r.raw_line_categories.functions;
8102 totals.classes += r.raw_line_categories.classes;
8103 totals.variables += r.raw_line_categories.variables;
8104 totals.imports += r.raw_line_categories.imports;
8105 totals.test_count += r.raw_line_categories.test_count;
8106 totals.test_assertion_count += r.raw_line_categories.test_assertion_count;
8107 totals.test_suite_count += r.raw_line_categories.test_suite_count;
8108 if let Some(cov) = &r.coverage {
8109 totals.coverage_lines_found += u64::from(cov.lines_found);
8110 totals.coverage_lines_hit += u64::from(cov.lines_hit);
8111 totals.coverage_functions_found += u64::from(cov.functions_found);
8112 totals.coverage_functions_hit += u64::from(cov.functions_hit);
8113 totals.coverage_branches_found += u64::from(cov.branches_found);
8114 totals.coverage_branches_hit += u64::from(cov.branches_hit);
8115 }
8116 }
8117 totals.files_considered = totals.files_analyzed;
8118 run.summary_totals = totals;
8119}
8120
8121fn fmt_delta(n: i64) -> String {
8122 if n > 0 {
8123 format!("+{n}")
8124 } else {
8125 format!("{n}")
8126 }
8127}
8128
8129fn delta_class(n: i64) -> &'static str {
8130 use std::cmp::Ordering;
8131 match n.cmp(&0) {
8132 Ordering::Greater => "pos",
8133 Ordering::Less => "neg",
8134 Ordering::Equal => "zero",
8135 }
8136}
8137
8138#[allow(clippy::cast_precision_loss)]
8140fn fmt_pct(delta: i64, baseline: u64) -> String {
8141 if baseline == 0 {
8142 return "—".to_string();
8143 }
8144 #[allow(clippy::cast_precision_loss)]
8145 let pct = (delta as f64 / baseline as f64) * 100.0;
8146 if pct > 0.049 {
8147 format!("+{pct:.1}%")
8148 } else if pct < -0.049 {
8149 format!("{pct:.1}%")
8150 } else {
8151 "±0%".to_string()
8152 }
8153}
8154
8155fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
8157 prev.map_or_else(
8158 || ("—".to_string(), "na"),
8159 |p| {
8160 #[allow(clippy::cast_possible_wrap)]
8161 let d = curr as i64 - p as i64;
8162 (fmt_delta(d), delta_class(d))
8163 },
8164 )
8165}
8166
8167#[allow(clippy::result_large_err)] fn load_scan_for_compare(
8169 json_path: &std::path::Path,
8170 scan_label: &str,
8171 run_id: &str,
8172 server_mode: bool,
8173 compare_url: &str,
8174 csp_nonce: &str,
8175) -> Result<sloc_core::AnalysisRun, axum::response::Response> {
8176 match read_json(json_path) {
8177 Ok(r) => Ok(r),
8178 Err(e) => {
8179 if server_mode {
8180 let html = ErrorTemplate {
8181 message: format!(
8182 "Could not load {scan_label} scan data. The scan output folder may have \
8183 been moved, renamed, or deleted. Re-running the analysis will create \
8184 fresh comparison data."
8185 ),
8186 last_report_url: Some("/compare-scans".to_string()),
8187 last_report_label: Some("Compare Scans".to_string()),
8188 run_id: Some(run_id.to_owned()),
8189 error_code: Some(404),
8190 csp_nonce: csp_nonce.to_owned(),
8191 version: env!("CARGO_PKG_VERSION"),
8192 }
8193 .render()
8194 .unwrap_or_else(|_| format!("<pre>{scan_label} load failed.</pre>"));
8195 return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
8196 }
8197 let msg = format!(
8198 "Could not load {scan_label} scan data.\n\nExpected path: {}\n\nError: {e}",
8199 json_path.display()
8200 );
8201 let folder_hint = output_folder_hint(json_path);
8202 Err(missing_scan_relocate_response(
8203 &msg,
8204 run_id,
8205 &folder_hint,
8206 compare_url,
8207 false,
8208 csp_nonce,
8209 ))
8210 }
8211 }
8212}
8213
8214struct ChurnStats {
8215 new_scope: bool,
8216 scope_flag: bool,
8217 churn_rate_str: String,
8218 churn_rate_class: String,
8219}
8220
8221fn compute_churn_stats(
8222 baseline_code: u64,
8223 current_code: u64,
8224 lines_added: i64,
8225 lines_removed: i64,
8226) -> ChurnStats {
8227 let new_scope = baseline_code == 0 && current_code > 0;
8228 #[allow(clippy::cast_precision_loss)]
8229 let churn_pct = if baseline_code > 0 {
8230 (lines_added + lines_removed) as f64 / baseline_code as f64 * 100.0
8231 } else {
8232 0.0
8233 };
8234 #[allow(clippy::cast_precision_loss)]
8235 let scope_flag =
8236 new_scope || (baseline_code > 0 && lines_added as f64 / baseline_code as f64 > 0.20);
8237 let churn_rate_str = if new_scope {
8238 "New".to_string()
8239 } else if baseline_code > 0 {
8240 format!("{churn_pct:.1}%")
8241 } else {
8242 "—".to_string()
8243 };
8244 let churn_rate_class = if new_scope || churn_pct > 20.0 {
8245 "high".to_string()
8246 } else if churn_pct > 5.0 {
8247 "med".to_string()
8248 } else {
8249 "low".to_string()
8250 };
8251 ChurnStats {
8252 new_scope,
8253 scope_flag,
8254 churn_rate_str,
8255 churn_rate_class,
8256 }
8257}
8258
8259fn build_coverage_delta_card(s: &sloc_core::SummaryDelta) -> String {
8263 let has_data = s.baseline_coverage_line_pct.is_some() || s.current_coverage_line_pct.is_some();
8264 if !has_data {
8265 return String::new();
8266 }
8267 let base_str = s
8268 .baseline_coverage_line_pct
8269 .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
8270 let curr_str = s
8271 .current_coverage_line_pct
8272 .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
8273 let (delta_str, cls) = match s.coverage_line_pct_delta {
8274 Some(d) if d > 0.0 => (format!("+{d:.1} pp"), "pos"),
8275 Some(d) if d < 0.0 => (format!("{d:.1} pp"), "neg"),
8276 Some(_) => ("\u{00b1}0.0 pp".into(), "zero"),
8277 None => ("\u{2014}".into(), "zero"),
8278 };
8279 format!(
8280 r#"<div class="delta-card">
8281 <div class="dc-tip">Line coverage % from LCOV/Cobertura/JaCoCo.<br>Positive delta = more lines instrumented and hit.<br>Only shown when at least one scan has coverage data.</div>
8282 <div class="delta-card-label">Line coverage</div>
8283 <div class="delta-card-from">Before: {base_str}</div>
8284 <div class="delta-card-to">{curr_str}</div>
8285 <span class="delta-card-change {cls}">{delta_str}</span>
8286 </div>"#
8287 )
8288}
8289
8290#[allow(clippy::ref_option)]
8292fn narrow_run_pair_by_scope(
8293 mut baseline: AnalysisRun,
8294 mut current: AnalysisRun,
8295 active_sub: &Option<String>,
8296 super_scope: bool,
8297) -> (AnalysisRun, AnalysisRun) {
8298 if let Some(ref sub_name) = active_sub {
8299 baseline
8300 .per_file_records
8301 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
8302 current
8303 .per_file_records
8304 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
8305 recompute_summary_from_records(&mut baseline);
8306 recompute_summary_from_records(&mut current);
8307 } else if super_scope {
8308 baseline.per_file_records.retain(|f| f.submodule.is_none());
8309 current.per_file_records.retain(|f| f.submodule.is_none());
8310 recompute_summary_from_records(&mut baseline);
8311 recompute_summary_from_records(&mut current);
8312 }
8313 (baseline, current)
8314}
8315
8316#[allow(clippy::ref_option)]
8318fn apply_scope_filter(runs: &mut [AnalysisRun], active_sub: &Option<String>, super_scope: bool) {
8319 if let Some(ref sub_name) = active_sub {
8320 for run in runs.iter_mut() {
8321 run.per_file_records
8322 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
8323 recompute_summary_from_records(run);
8324 }
8325 } else if super_scope {
8326 for run in runs.iter_mut() {
8327 run.per_file_records.retain(|f| f.submodule.is_none());
8328 recompute_summary_from_records(run);
8329 }
8330 }
8331}
8332
8333#[allow(clippy::too_many_lines)]
8334async fn compare_handler(
8335 State(state): State<AppState>,
8336 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8337 Query(query): Query<CompareQuery>,
8338) -> impl IntoResponse {
8339 let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
8342 (Some(a), Some(b)) => (a.to_string(), b.to_string()),
8343 _ => return axum::response::Redirect::to("/compare-scans").into_response(),
8344 };
8345
8346 let (maybe_a, maybe_b) = {
8347 let reg = state.registry.lock().await;
8348 (
8349 reg.find_by_run_id(&run_id_a).cloned(),
8350 reg.find_by_run_id(&run_id_b).cloned(),
8351 )
8352 };
8353
8354 let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
8355 let html = ErrorTemplate {
8356 message: "One or both run IDs were not found in scan history. \
8357 The runs may have been deleted or the registry may have been reset."
8358 .to_string(),
8359 last_report_url: Some("/compare-scans".to_string()),
8360 last_report_label: Some("Compare Scans".to_string()),
8361 run_id: None,
8362 error_code: None,
8363 csp_nonce: csp_nonce.clone(),
8364 version: env!("CARGO_PKG_VERSION"),
8365 }
8366 .render()
8367 .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
8368 return Html(html).into_response();
8369 };
8370
8371 let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
8373 (entry_a, entry_b)
8374 } else {
8375 (entry_b, entry_a)
8376 };
8377
8378 if baseline_entry.run_id != run_id_a {
8382 let canonical = format!(
8383 "/compare?a={}&b={}",
8384 baseline_entry.run_id, current_entry.run_id
8385 );
8386 return axum::response::Redirect::to(&canonical).into_response();
8387 }
8388
8389 let (Some(base_json), Some(curr_json)) = (
8390 baseline_entry.json_path.as_ref(),
8391 current_entry.json_path.as_ref(),
8392 ) else {
8393 let html = ErrorTemplate {
8394 message: "Full comparison requires JSON scan data, which was not saved for one or \
8395 both of these runs. JSON is now always saved for new scans — re-run the \
8396 affected projects to enable comparisons."
8397 .to_string(),
8398 last_report_url: Some("/compare-scans".to_string()),
8399 last_report_label: Some("Compare Scans".to_string()),
8400 run_id: None,
8401 error_code: None,
8402 csp_nonce: csp_nonce.clone(),
8403 version: env!("CARGO_PKG_VERSION"),
8404 }
8405 .render()
8406 .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
8407 return Html(html).into_response();
8408 };
8409
8410 let compare_url = format!(
8411 "/compare?a={}&b={}",
8412 baseline_entry.run_id, current_entry.run_id
8413 );
8414
8415 let baseline_run = match load_scan_for_compare(
8416 base_json,
8417 "baseline",
8418 &baseline_entry.run_id,
8419 state.server_mode,
8420 &compare_url,
8421 &csp_nonce,
8422 ) {
8423 Ok(r) => r,
8424 Err(resp) => return resp,
8425 };
8426 let current_run = match load_scan_for_compare(
8427 curr_json,
8428 "current",
8429 ¤t_entry.run_id,
8430 state.server_mode,
8431 &compare_url,
8432 &csp_nonce,
8433 ) {
8434 Ok(r) => r,
8435 Err(resp) => return resp,
8436 };
8437
8438 let active_submodule = query.sub.clone();
8439 let super_scope_active = query.scope.as_deref() == Some("super");
8440
8441 let submodule_options = baseline_run
8442 .submodule_summaries
8443 .iter()
8444 .chain(current_run.submodule_summaries.iter())
8445 .map(|s| s.name.clone())
8446 .collect::<std::collections::BTreeSet<_>>()
8447 .into_iter()
8448 .collect::<Vec<_>>();
8449 let has_any_submodule_data = !submodule_options.is_empty();
8450
8451 let (effective_baseline, effective_current) = narrow_run_pair_by_scope(
8453 baseline_run,
8454 current_run,
8455 &active_submodule,
8456 super_scope_active,
8457 );
8458
8459 let comparison = compute_delta(&effective_baseline, &effective_current);
8460
8461 let file_rows: Vec<CompareFileDeltaRow> = comparison
8462 .file_deltas
8463 .iter()
8464 .map(|d| CompareFileDeltaRow {
8465 relative_path: d.relative_path.clone(),
8466 language: d.language.clone().unwrap_or_else(|| "—".into()),
8467 status: match d.status {
8468 FileChangeStatus::Added => "added".into(),
8469 FileChangeStatus::Removed => "removed".into(),
8470 FileChangeStatus::Modified => "modified".into(),
8471 FileChangeStatus::Unchanged => "unchanged".into(),
8472 },
8473 baseline_code: d.baseline_code,
8474 current_code: d.current_code,
8475 baseline_code_display: if d.status == FileChangeStatus::Added {
8476 "—".into()
8477 } else {
8478 d.baseline_code.to_string()
8479 },
8480 current_code_display: if d.status == FileChangeStatus::Removed {
8481 "—".into()
8482 } else {
8483 d.current_code.to_string()
8484 },
8485 code_delta_str: fmt_delta(d.code_delta),
8486 code_delta_class: delta_class(d.code_delta).into(),
8487 comment_delta_str: fmt_delta(d.comment_delta),
8488 comment_delta_class: delta_class(d.comment_delta).into(),
8489 total_delta_str: fmt_delta(d.total_delta),
8490 total_delta_class: delta_class(d.total_delta).into(),
8491 })
8492 .collect();
8493
8494 let project_path = baseline_entry
8495 .input_roots
8496 .first()
8497 .map(|s| sanitize_path_str(s))
8498 .unwrap_or_default();
8499 let lines_added = sum_added_code_lines(&comparison);
8500 let lines_removed = sum_removed_code_lines(&comparison);
8501 let churn = compute_churn_stats(
8502 comparison.summary.baseline_code,
8503 comparison.summary.current_code,
8504 lines_added,
8505 lines_removed,
8506 );
8507 let s = &comparison.summary;
8508 let template = CompareTemplate {
8509 loading_overlay: loading_overlay_block(&csp_nonce, "Loading scan delta"),
8510 version: env!("CARGO_PKG_VERSION"),
8511 project_label: baseline_entry.project_label.clone(),
8512 baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
8513 current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
8514 baseline_run_id: baseline_entry.run_id.clone(),
8515 current_run_id: current_entry.run_id.clone(),
8516 baseline_run_id_short: baseline_entry
8517 .run_id
8518 .split('-')
8519 .next_back()
8520 .unwrap_or(&baseline_entry.run_id)
8521 .chars()
8522 .take(7)
8523 .collect(),
8524 current_run_id_short: current_entry
8525 .run_id
8526 .split('-')
8527 .next_back()
8528 .unwrap_or(¤t_entry.run_id)
8529 .chars()
8530 .take(7)
8531 .collect(),
8532 baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
8533 baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
8534 current_timestamp: fmt_la_time(current_entry.timestamp_utc),
8535 current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
8536 project_path: project_path.clone(),
8537 baseline_code: s.baseline_code,
8538 current_code: s.current_code,
8539 code_lines_delta_str: fmt_delta(s.code_lines_delta),
8540 code_lines_delta_class: delta_class(s.code_lines_delta).into(),
8541 baseline_files: s.baseline_files,
8542 current_files: s.current_files,
8543 files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
8544 files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
8545 baseline_comments: s.baseline_comments,
8546 current_comments: s.current_comments,
8547 comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
8548 comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
8549 baseline_code_fmt: fmt_comma(s.baseline_code.cast_signed()),
8550 current_code_fmt: fmt_comma(s.current_code.cast_signed()),
8551 baseline_files_fmt: fmt_comma(s.baseline_files.cast_signed()),
8552 current_files_fmt: fmt_comma(s.current_files.cast_signed()),
8553 baseline_comments_fmt: fmt_comma(s.baseline_comments.cast_signed()),
8554 current_comments_fmt: fmt_comma(s.current_comments.cast_signed()),
8555 code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
8556 files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
8557 comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
8558 code_lines_added: lines_added,
8559 code_lines_removed: lines_removed,
8560 code_lines_modified: sum_modified_code_lines(&comparison),
8561 code_lines_unmodified: sum_unmodified_code_lines(&comparison),
8562 new_scope: churn.new_scope,
8563 churn_rate_str: churn.churn_rate_str,
8564 churn_rate_class: churn.churn_rate_class,
8565 scope_flag: churn.scope_flag,
8566 files_added: comparison.files_added,
8567 files_removed: comparison.files_removed,
8568 files_modified: comparison.files_modified,
8569 files_unchanged: comparison.files_unchanged,
8570 file_rows,
8571 baseline_git_author: baseline_entry.git_author.clone(),
8572 current_git_author: current_entry.git_author.clone(),
8573 baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
8574 current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
8575 baseline_git_tags: baseline_entry.git_tags.clone(),
8576 current_git_tags: current_entry.git_tags.clone(),
8577 baseline_git_commit_date: baseline_entry
8578 .git_commit_date
8579 .as_deref()
8580 .and_then(fmt_git_date),
8581 current_git_commit_date: current_entry
8582 .git_commit_date
8583 .as_deref()
8584 .and_then(fmt_git_date),
8585 project_name: project_path
8586 .rsplit(['/', '\\'])
8587 .find(|s| !s.is_empty())
8588 .unwrap_or(&project_path)
8589 .to_string(),
8590 submodule_options,
8591 has_any_submodule_data,
8592 active_submodule,
8593 super_scope_active,
8594 toast_assets: sloc_toast_assets(&csp_nonce),
8595 csp_nonce,
8596 coverage_delta_card: build_coverage_delta_card(s),
8597 baseline_test_count: effective_baseline.summary_totals.test_count,
8598 current_test_count: effective_current.summary_totals.test_count,
8599 baseline_coverage_pct: s.baseline_coverage_line_pct,
8600 current_coverage_pct: s.current_coverage_line_pct,
8601 };
8602
8603 Html(
8604 template
8605 .render()
8606 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
8607 )
8608 .into_response()
8609}
8610
8611fn format_number(n: u64) -> String {
8619 let s = n.to_string();
8620 let mut out = String::with_capacity(s.len() + s.len() / 3);
8621 let len = s.len();
8622 for (i, c) in s.chars().enumerate() {
8623 if i > 0 && (len - i).is_multiple_of(3) {
8624 out.push(',');
8625 }
8626 out.push(c);
8627 }
8628 out
8629}
8630
8631const fn badge_char_width(c: char) -> f64 {
8632 match c {
8633 'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
8634 'm' | 'w' => 9.0,
8635 ' ' => 4.0,
8636 _ => 6.5,
8637 }
8638}
8639
8640#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
8641fn badge_text_px(text: &str) -> u32 {
8642 text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
8643}
8644
8645fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
8646 let lw = badge_text_px(label) + 20;
8647 let rw = badge_text_px(value) + 20;
8648 let total = lw + rw;
8649 let lx = lw / 2;
8650 let rx = lw + rw / 2;
8651 let le = escape_html(label);
8652 let ve = escape_html(value);
8653 let ce = escape_html(color);
8654 format!(
8655 r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
8656 <rect width="{total}" height="20" fill="#555"/>
8657 <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
8658 <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
8659 <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
8660 <text x="{lx}" y="13">{le}</text>
8661 <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
8662 <text x="{rx}" y="13">{ve}</text>
8663 </g>
8664</svg>"##
8665 )
8666}
8667
8668#[derive(Deserialize)]
8669struct BadgeQuery {
8670 label: Option<String>,
8671 color: Option<String>,
8672}
8673
8674async fn badge_handler(
8675 State(state): State<AppState>,
8676 AxumPath(metric): AxumPath<String>,
8677 Query(query): Query<BadgeQuery>,
8678) -> Response {
8679 let entry = {
8680 let reg = state.registry.lock().await;
8681 reg.entries.first().cloned()
8682 };
8683
8684 let Some(entry) = entry else {
8685 let svg = render_badge_svg("oxide-sloc", "no data", "#999");
8686 return (
8687 [
8688 (header::CONTENT_TYPE, "image/svg+xml"),
8689 (header::CACHE_CONTROL, "no-cache, max-age=0"),
8690 ],
8691 svg,
8692 )
8693 .into_response();
8694 };
8695
8696 let (default_label, value, default_color) = match metric.as_str() {
8697 "code-lines" => (
8698 "code lines",
8699 format_number(entry.summary.code_lines),
8700 "#4a78ee",
8701 ),
8702 "files" => (
8703 "files analyzed",
8704 format_number(entry.summary.files_analyzed),
8705 "#4a9862",
8706 ),
8707 "comment-lines" => (
8708 "comment lines",
8709 format_number(entry.summary.comment_lines),
8710 "#b35428",
8711 ),
8712 "blank-lines" => (
8713 "blank lines",
8714 format_number(entry.summary.blank_lines),
8715 "#7a5db0",
8716 ),
8717 _ => return StatusCode::NOT_FOUND.into_response(),
8718 };
8719
8720 let label = query.label.as_deref().unwrap_or(default_label);
8721 let color = query.color.as_deref().unwrap_or(default_color);
8722 let svg = render_badge_svg(label, &value, color);
8723
8724 (
8725 [
8726 (header::CONTENT_TYPE, "image/svg+xml"),
8727 (header::CACHE_CONTROL, "no-cache, max-age=0"),
8728 ],
8729 svg,
8730 )
8731 .into_response()
8732}
8733
8734#[derive(Serialize)]
8742struct ApiCoverageBlock {
8743 lines_found: u64,
8744 lines_hit: u64,
8745 line_pct: f64,
8746 functions_found: u64,
8747 functions_hit: u64,
8748 function_pct: f64,
8749 branches_found: u64,
8750 branches_hit: u64,
8751 branch_pct: f64,
8752}
8753
8754#[derive(Serialize)]
8755struct ApiMetricsResponse {
8756 run_id: String,
8757 timestamp: String,
8758 project: String,
8759 summary: ApiSummaryPayload,
8760 languages: Vec<ApiLanguageRow>,
8761 #[serde(skip_serializing_if = "Option::is_none")]
8762 coverage: Option<ApiCoverageBlock>,
8763}
8764
8765#[derive(Serialize)]
8766struct ApiSummaryPayload {
8767 files_analyzed: u64,
8768 files_skipped: u64,
8769 code_lines: u64,
8770 comment_lines: u64,
8771 blank_lines: u64,
8772 total_physical_lines: u64,
8773 functions: u64,
8774 classes: u64,
8775 variables: u64,
8776 imports: u64,
8777}
8778
8779#[derive(Serialize)]
8780struct ApiLanguageRow {
8781 name: String,
8782 files: u64,
8783 code_lines: u64,
8784 comment_lines: u64,
8785 blank_lines: u64,
8786 functions: u64,
8787 classes: u64,
8788 variables: u64,
8789 imports: u64,
8790}
8791
8792async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
8793 let entry = {
8794 let reg = state.registry.lock().await;
8795 reg.entries.first().cloned()
8796 };
8797 entry.map_or_else(
8798 || error::not_found("no scans recorded yet"),
8799 |e| build_metrics_response(&e),
8800 )
8801}
8802
8803async fn api_metrics_run_handler(
8804 State(state): State<AppState>,
8805 AxumPath(run_id): AxumPath<String>,
8806) -> Response {
8807 let entry = {
8808 let reg = state.registry.lock().await;
8809 reg.find_by_run_id(&run_id).cloned()
8810 };
8811 entry.map_or_else(
8812 || error::not_found("run not found"),
8813 |e| build_metrics_response(&e),
8814 )
8815}
8816
8817fn build_metrics_response(entry: &RegistryEntry) -> Response {
8818 let languages: Vec<ApiLanguageRow> = entry
8819 .json_path
8820 .as_ref()
8821 .and_then(|p| read_json(p).ok())
8822 .map(|run| {
8823 run.totals_by_language
8824 .iter()
8825 .map(|l| ApiLanguageRow {
8826 name: l.language.display_name().to_string(),
8827 files: l.files,
8828 code_lines: l.code_lines,
8829 comment_lines: l.comment_lines,
8830 blank_lines: l.blank_lines,
8831 functions: l.functions,
8832 classes: l.classes,
8833 variables: l.variables,
8834 imports: l.imports,
8835 })
8836 .collect()
8837 })
8838 .unwrap_or_default();
8839
8840 let s = &entry.summary;
8841 let coverage = if s.coverage_lines_found > 0 {
8842 let pct = |hit: u64, found: u64| -> f64 {
8843 if found == 0 {
8844 0.0
8845 } else {
8846 #[allow(clippy::cast_precision_loss)]
8847 let v = (hit as f64 / found as f64) * 100.0;
8848 (v * 10.0).round() / 10.0
8849 }
8850 };
8851 Some(ApiCoverageBlock {
8852 lines_found: s.coverage_lines_found,
8853 lines_hit: s.coverage_lines_hit,
8854 line_pct: pct(s.coverage_lines_hit, s.coverage_lines_found),
8855 functions_found: s.coverage_functions_found,
8856 functions_hit: s.coverage_functions_hit,
8857 function_pct: pct(s.coverage_functions_hit, s.coverage_functions_found),
8858 branches_found: s.coverage_branches_found,
8859 branches_hit: s.coverage_branches_hit,
8860 branch_pct: pct(s.coverage_branches_hit, s.coverage_branches_found),
8861 })
8862 } else {
8863 None
8864 };
8865 Json(ApiMetricsResponse {
8866 run_id: entry.run_id.clone(),
8867 timestamp: entry.timestamp_utc.to_rfc3339(),
8868 project: entry.project_label.clone(),
8869 summary: ApiSummaryPayload {
8870 files_analyzed: s.files_analyzed,
8871 files_skipped: s.files_skipped,
8872 code_lines: s.code_lines,
8873 comment_lines: s.comment_lines,
8874 blank_lines: s.blank_lines,
8875 total_physical_lines: s.total_physical_lines,
8876 functions: s.functions,
8877 classes: s.classes,
8878 variables: s.variables,
8879 imports: s.imports,
8880 },
8881 languages,
8882 coverage,
8883 })
8884 .into_response()
8885}
8886
8887#[derive(Deserialize)]
8894struct ProjectHistoryQuery {
8895 path: Option<String>,
8896}
8897
8898#[derive(Serialize)]
8899struct ProjectHistoryResponse {
8900 scan_count: usize,
8901 last_scan_id: Option<String>,
8902 last_scan_timestamp: Option<String>,
8903 last_scan_code_lines: Option<u64>,
8904 last_git_branch: Option<String>,
8905 last_git_commit: Option<String>,
8906}
8907
8908fn entry_matches_project(
8911 entry: &RegistryEntry,
8912 root_str: &str,
8913 upload_root: &str,
8914 upload_name_suffix: Option<&str>,
8915) -> bool {
8916 if entry.input_roots.iter().any(|r| r == root_str) {
8917 return true;
8918 }
8919 if let Some(suffix) = upload_name_suffix {
8920 return entry
8921 .input_roots
8922 .iter()
8923 .any(|r| r.starts_with(upload_root) && r.ends_with(suffix));
8924 }
8925 false
8926}
8927
8928async fn project_history_handler(
8929 State(state): State<AppState>,
8930 Query(query): Query<ProjectHistoryQuery>,
8931) -> Response {
8932 let path = query.path.unwrap_or_default();
8933 let resolved = resolve_input_path(&path);
8934 let root_str = resolved.to_string_lossy().replace('\\', "/");
8935
8936 let upload_root = std::env::temp_dir()
8941 .join("oxide-sloc-uploads")
8942 .to_string_lossy()
8943 .replace('\\', "/");
8944 let upload_name_suffix: Option<String> =
8945 if state.server_mode && root_str.starts_with(&upload_root) {
8946 resolved
8947 .file_name()
8948 .and_then(|n| n.to_str())
8949 .map(|name| format!("/{name}"))
8950 } else {
8951 None
8952 };
8953 let suffix_ref = upload_name_suffix.as_deref();
8954
8955 let entries: Vec<_> = {
8956 let reg = state.registry.lock().await;
8957 reg.entries
8958 .iter()
8959 .filter(|e| entry_matches_project(e, &root_str, &upload_root, suffix_ref))
8960 .cloned()
8961 .collect()
8962 };
8963 let scan_count = entries.len();
8964 let last = entries.first();
8965 let last_scan_id = last.map(|e| e.run_id.clone());
8966 let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
8967 let last_scan_code_lines = last.map(|e| e.summary.code_lines);
8968 let last_git_branch = last.and_then(|e| e.git_branch.clone());
8969 let last_git_commit = last.and_then(|e| e.git_commit.clone());
8970
8971 Json(ProjectHistoryResponse {
8972 scan_count,
8973 last_scan_id,
8974 last_scan_timestamp,
8975 last_scan_code_lines,
8976 last_git_branch,
8977 last_git_commit,
8978 })
8979 .into_response()
8980}
8981
8982#[derive(Deserialize)]
8989struct MetricsHistoryQuery {
8990 root: Option<String>,
8991 limit: Option<usize>,
8992 submodule: Option<String>,
8995}
8996
8997#[derive(Serialize)]
8998struct MetricsSubmoduleLink {
8999 name: String,
9000 url: String,
9001}
9002
9003#[derive(Serialize)]
9004struct MetricsHistoryEntry {
9005 run_id: String,
9006 run_id_short: String,
9007 timestamp: String,
9008 commit: Option<String>,
9009 branch: Option<String>,
9010 tags: Vec<String>,
9011 nearest_tag: Option<String>,
9012 code_lines: u64,
9013 comment_lines: u64,
9014 blank_lines: u64,
9015 physical_lines: u64,
9016 files_analyzed: u64,
9017 files_skipped: u64,
9018 test_count: u64,
9019 project_label: String,
9020 html_url: Option<String>,
9021 has_pdf: bool,
9022 submodule_links: Vec<MetricsSubmoduleLink>,
9023 #[serde(skip_serializing_if = "Option::is_none")]
9025 coverage_line_pct: Option<f64>,
9026}
9027
9028fn build_entry_submodule_links(e: &sloc_core::history::RegistryEntry) -> Vec<MetricsSubmoduleLink> {
9029 let mut links: Vec<MetricsSubmoduleLink> = vec![];
9030 let sub_dir = e
9031 .html_path
9032 .as_ref()
9033 .and_then(|p| p.parent())
9034 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
9035 let Some(dir) = sub_dir else { return links };
9036 let Ok(rd) = std::fs::read_dir(dir) else {
9037 return links;
9038 };
9039 for entry_res in rd.flatten() {
9040 let fname = entry_res.file_name();
9041 let fname_str = fname.to_string_lossy();
9042 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
9043 let stem = &fname_str[..fname_str.len() - 5];
9044 let display = stem[4..].replace('-', " ");
9045 links.push(MetricsSubmoduleLink {
9046 name: display,
9047 url: format!("/runs/{stem}/{}", e.run_id),
9048 });
9049 }
9050 }
9051 links.sort_by(|a, b| a.name.cmp(&b.name));
9052 links
9053}
9054
9055fn apply_submodule_filter(
9056 base: MetricsHistoryEntry,
9057 filter: &str,
9058 e: &sloc_core::history::RegistryEntry,
9059) -> Option<MetricsHistoryEntry> {
9060 let json_path = e.json_path.as_ref()?;
9061 let json_str = std::fs::read_to_string(json_path).ok()?;
9062 let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
9063 let sub = run
9064 .submodule_summaries
9065 .iter()
9066 .find(|s| s.name.to_lowercase() == filter || s.relative_path.to_lowercase() == filter)?;
9067 let safe = sanitize_project_label(&sub.name);
9068 let artifact_key = format!("sub_{safe}");
9069 let sub_html_url = std::path::Path::new(json_path).parent().map_or_else(
9070 || base.html_url.clone(),
9071 |run_dir| {
9072 let sub_path = run_dir.join(format!("{artifact_key}.html"));
9073 if sub_path.exists() {
9074 Some(format!("/runs/{artifact_key}/{}", e.run_id))
9075 } else {
9076 base.html_url.clone()
9077 }
9078 },
9079 );
9080
9081 let sub_files: Vec<_> = run
9084 .per_file_records
9085 .iter()
9086 .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
9087 .collect();
9088 let test_count: u64 = sub_files
9089 .iter()
9090 .map(|r| r.raw_line_categories.test_count)
9091 .sum();
9092 #[allow(clippy::cast_precision_loss)]
9093 let coverage_line_pct: Option<f64> = {
9094 let found: u64 = sub_files
9095 .iter()
9096 .filter_map(|r| r.coverage.as_ref())
9097 .map(|c| u64::from(c.lines_found))
9098 .sum();
9099 let hit: u64 = sub_files
9100 .iter()
9101 .filter_map(|r| r.coverage.as_ref())
9102 .map(|c| u64::from(c.lines_hit))
9103 .sum();
9104 if found > 0 {
9105 let pct = (hit as f64 / found as f64) * 100.0;
9106 Some((pct * 10.0).round() / 10.0)
9107 } else {
9108 None
9109 }
9110 };
9111
9112 Some(MetricsHistoryEntry {
9113 code_lines: sub.code_lines,
9114 comment_lines: sub.comment_lines,
9115 blank_lines: sub.blank_lines,
9116 physical_lines: sub.total_physical_lines,
9117 files_analyzed: sub.files_analyzed,
9118 files_skipped: 0,
9119 test_count,
9120 html_url: sub_html_url,
9121 has_pdf: false,
9122 submodule_links: vec![],
9123 coverage_line_pct,
9124 ..base
9125 })
9126}
9127
9128#[allow(clippy::too_many_lines)] async fn api_metrics_history_handler(
9130 State(state): State<AppState>,
9131 Query(query): Query<MetricsHistoryQuery>,
9132) -> Response {
9133 let limit = query.limit.unwrap_or(50).min(500);
9134 let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
9135
9136 let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
9137 let reg = state.registry.lock().await;
9138 reg.entries
9139 .iter()
9140 .filter(|e| {
9141 query.root.as_ref().is_none_or(|root| {
9142 let resolved = resolve_input_path(root);
9143 let root_str = resolved.to_string_lossy().replace('\\', "/");
9144 e.input_roots.iter().any(|r| r == &root_str)
9145 })
9146 })
9147 .take(limit)
9148 .cloned()
9149 .collect()
9150 };
9151
9152 let entries: Vec<MetricsHistoryEntry> = candidate_entries
9153 .into_iter()
9154 .filter_map(|e| {
9155 let tags = e
9156 .git_tags
9157 .as_deref()
9158 .map(|s| {
9159 s.split(',')
9160 .map(|t| t.trim().to_string())
9161 .filter(|t| !t.is_empty())
9162 .collect()
9163 })
9164 .unwrap_or_default();
9165 let html_url = e
9166 .html_path
9167 .as_ref()
9168 .filter(|p| p.exists())
9169 .map(|_| format!("/runs/html/{}", e.run_id));
9170 let nearest_tag = e.git_nearest_tag.clone();
9171 let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
9172 let run_id_short: String = e
9173 .run_id
9174 .split('-')
9175 .next_back()
9176 .unwrap_or(&e.run_id)
9177 .chars()
9178 .take(7)
9179 .collect();
9180 let submodule_links = build_entry_submodule_links(&e);
9181 #[allow(clippy::cast_precision_loss)]
9182 let coverage_line_pct = if e.summary.coverage_lines_found > 0 {
9183 let pct = (e.summary.coverage_lines_hit as f64
9184 / e.summary.coverage_lines_found as f64)
9185 * 100.0;
9186 Some((pct * 10.0).round() / 10.0)
9187 } else {
9188 None
9189 };
9190 let base = MetricsHistoryEntry {
9191 run_id: e.run_id.clone(),
9192 run_id_short,
9193 timestamp: e.timestamp_utc.to_rfc3339(),
9194 commit: e.git_commit.clone(),
9195 branch: e.git_branch.clone(),
9196 tags,
9197 nearest_tag,
9198 code_lines: e.summary.code_lines,
9199 comment_lines: e.summary.comment_lines,
9200 blank_lines: e.summary.blank_lines,
9201 physical_lines: e.summary.total_physical_lines,
9202 files_analyzed: e.summary.files_analyzed,
9203 files_skipped: e.summary.files_skipped,
9204 test_count: e.summary.test_count,
9205 project_label: e.project_label.clone(),
9206 html_url,
9207 has_pdf,
9208 submodule_links,
9209 coverage_line_pct,
9210 };
9211 if let Some(ref filter) = submodule_filter {
9212 apply_submodule_filter(base, filter, &e)
9213 } else {
9214 Some(base)
9215 }
9216 })
9217 .collect();
9218
9219 Json(entries).into_response()
9220}
9221
9222#[derive(Serialize)]
9224struct ChurnEntry {
9225 run_id: String,
9226 added: i64,
9227 removed: i64,
9228 modified: i64,
9229 unmodified: i64,
9230}
9231
9232async fn api_metrics_churn_handler(
9237 State(state): State<AppState>,
9238 Query(query): Query<MetricsHistoryQuery>,
9239) -> Response {
9240 let limit = query.limit.unwrap_or(200).min(500);
9241 let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
9242 let reg = state.registry.lock().await;
9243 reg.entries
9244 .iter()
9245 .filter(|e| {
9246 query.root.as_ref().is_none_or(|root| {
9247 let resolved = resolve_input_path(root);
9248 let root_str = resolved.to_string_lossy().replace('\\', "/");
9249 e.input_roots.iter().any(|r| r == &root_str)
9250 })
9251 })
9252 .take(limit)
9253 .cloned()
9254 .collect()
9255 };
9256 let mut by_project: std::collections::HashMap<String, Vec<sloc_core::history::RegistryEntry>> =
9257 std::collections::HashMap::new();
9258 for e in candidate_entries {
9259 by_project
9260 .entry(e.project_label.clone())
9261 .or_default()
9262 .push(e);
9263 }
9264 let mut out: Vec<ChurnEntry> = Vec::new();
9265 for (_proj, mut entries) in by_project {
9266 entries.sort_by_key(|e| e.timestamp_utc);
9267 let mut prev_run: Option<sloc_core::AnalysisRun> = None;
9268 for e in &entries {
9269 let curr = e
9270 .json_path
9271 .as_ref()
9272 .and_then(|path| sloc_core::read_json(path).ok());
9273 if let (Some(prev), Some(cur)) = (prev_run.as_ref(), curr.as_ref()) {
9274 let cmp = sloc_core::compute_delta(prev, cur);
9275 out.push(ChurnEntry {
9276 run_id: e.run_id.clone(),
9277 added: sum_added_code_lines(&cmp),
9278 removed: sum_removed_code_lines(&cmp),
9279 modified: sum_modified_code_lines(&cmp),
9280 unmodified: sum_unmodified_code_lines(&cmp),
9281 });
9282 } else {
9283 out.push(ChurnEntry {
9284 run_id: e.run_id.clone(),
9285 added: 0,
9286 removed: 0,
9287 modified: 0,
9288 unmodified: 0,
9289 });
9290 }
9291 if curr.is_some() {
9292 prev_run = curr;
9293 }
9294 }
9295 }
9296 Json(out).into_response()
9297}
9298
9299#[derive(Deserialize)]
9303struct MetricsSubmodulesQuery {
9304 root: Option<String>,
9305}
9306
9307#[derive(Serialize)]
9308struct SubmoduleEntry {
9309 name: String,
9310 relative_path: String,
9311}
9312
9313async fn api_metrics_submodules_handler(
9314 State(state): State<AppState>,
9315 Query(query): Query<MetricsSubmodulesQuery>,
9316) -> Response {
9317 let json_paths: Vec<std::path::PathBuf> = {
9318 let reg = state.registry.lock().await;
9319 reg.entries
9320 .iter()
9321 .filter(|e| {
9322 query.root.as_ref().is_none_or(|root| {
9323 let resolved = resolve_input_path(root);
9324 let root_str = resolved.to_string_lossy().replace('\\', "/");
9325 e.input_roots.iter().any(|r| r == &root_str)
9326 })
9327 })
9328 .filter_map(|e| e.json_path.clone())
9329 .collect()
9330 };
9331
9332 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
9333 let mut result: Vec<SubmoduleEntry> = Vec::new();
9334
9335 for path in &json_paths {
9336 let Ok(json_str) = tokio::fs::read_to_string(path).await else {
9337 continue;
9338 };
9339 let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
9340 continue;
9341 };
9342 for sub in &run.submodule_summaries {
9343 if seen.insert(sub.name.clone()) {
9344 result.push(SubmoduleEntry {
9345 name: sub.name.clone(),
9346 relative_path: sub.relative_path.clone(),
9347 });
9348 }
9349 }
9350 }
9351
9352 result.sort_by(|a, b| a.name.cmp(&b.name));
9353 Json(result).into_response()
9354}
9355
9356#[derive(Deserialize)]
9365struct IngestQuery {
9366 label: Option<String>,
9367}
9368
9369#[derive(Serialize)]
9370struct IngestResponse {
9371 run_id: String,
9372 view_url: String,
9373}
9374
9375async fn api_ingest_handler(
9376 State(state): State<AppState>,
9377 Query(q): Query<IngestQuery>,
9378 Json(run): Json<sloc_core::AnalysisRun>,
9379) -> Response {
9380 let label = q.label.unwrap_or_else(|| {
9381 run.input_roots
9382 .first()
9383 .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
9384 });
9385
9386 let label_for_task = label.clone();
9387 let result = tokio::task::spawn_blocking(move || {
9388 let html = render_html(&run)?;
9389 let run_id = run.tool.run_id.clone();
9390 let run_id_safe = run_id.len() <= 128
9391 && !run_id.is_empty()
9392 && run_id
9393 .chars()
9394 .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
9395 if !run_id_safe {
9396 anyhow::bail!(
9397 "invalid run_id: must be 1-128 alphanumeric/dash/underscore/dot characters"
9398 );
9399 }
9400 let project_label = sanitize_project_label(&label_for_task);
9401 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
9402 let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
9403 Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
9404 _ => project_label,
9405 };
9406 let (artifacts, _pending_pdf) = persist_run_artifacts(
9407 &run,
9408 &html,
9409 &output_dir,
9410 &label_for_task,
9411 &file_stem,
9412 RunResultContext::default(),
9413 )?;
9414 Ok::<_, anyhow::Error>((run_id, artifacts, run))
9415 })
9416 .await;
9417
9418 match result {
9419 Ok(Ok((run_id, artifacts, run))) => {
9420 register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
9421 (
9422 StatusCode::CREATED,
9423 Json(IngestResponse {
9424 view_url: format!("/view-reports?run_id={run_id}"),
9425 run_id,
9426 }),
9427 )
9428 .into_response()
9429 }
9430 Ok(Err(e)) => error::internal(&format!("{e:#}")),
9431 Err(e) => error::internal(&format!("{e}")),
9432 }
9433}
9434
9435fn html_escape(s: &str) -> String {
9439 s.replace('&', "&")
9440 .replace('<', "<")
9441 .replace('>', ">")
9442 .replace('"', """)
9443}
9444
9445#[allow(clippy::cast_precision_loss)]
9446fn fmt_num(n: i64) -> String {
9447 let a = n.unsigned_abs();
9448 if a >= 1_000_000 {
9449 let v = n as f64 / 1_000_000.0;
9450 let s = format!("{v:.1}");
9451 format!("{}M", s.trim_end_matches(".0"))
9452 } else if a >= 10_000 {
9453 let v = n as f64 / 1_000.0;
9454 let s = format!("{v:.1}");
9455 format!("{}K", s.trim_end_matches(".0"))
9456 } else {
9457 let sign = if n < 0 { "-" } else { "" };
9458 if a < 1_000 {
9459 return format!("{sign}{a}");
9460 }
9461 format!("{sign}{},{:03}", a / 1_000, a % 1_000)
9462 }
9463}
9464
9465fn fmt_comma(n: i64) -> String {
9466 let sign = if n < 0 { "-" } else { "" };
9467 let a = n.unsigned_abs();
9468 if a < 1_000 {
9469 return format!("{sign}{a}");
9470 }
9471 let s = a.to_string();
9472 let bytes = s.as_bytes();
9473 let len = bytes.len();
9474 let mut out = String::with_capacity(len + len / 3);
9475 for (i, &b) in bytes.iter().enumerate() {
9476 if i > 0 && (len - i).is_multiple_of(3) {
9477 out.push(',');
9478 }
9479 out.push(b as char);
9480 }
9481 format!("{sign}{out}")
9482}
9483
9484fn group_thousands(s: &str) -> String {
9491 let (sign, rest) = match s.as_bytes().first() {
9492 Some(b'-') => ("-", &s[1..]),
9493 Some(b'+') => ("+", &s[1..]),
9494 _ => ("", s),
9495 };
9496 let (int_part, frac_part) = match rest.split_once('.') {
9497 Some((i, f)) => (i, Some(f)),
9498 None => (rest, None),
9499 };
9500 if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
9501 return s.to_string();
9502 }
9503 let bytes = int_part.as_bytes();
9504 let len = bytes.len();
9505 let mut grouped = String::with_capacity(len + len / 3);
9506 for (i, &b) in bytes.iter().enumerate() {
9507 if i > 0 && (len - i).is_multiple_of(3) {
9508 grouped.push(',');
9509 }
9510 grouped.push(b as char);
9511 }
9512 frac_part.map_or_else(
9513 || format!("{sign}{grouped}"),
9514 |f| format!("{sign}{grouped}.{f}"),
9515 )
9516}
9517
9518mod filters {
9520 #![allow(clippy::inline_always, clippy::unused_self, clippy::unnecessary_wraps)]
9523 use askama::{Result, Values};
9524
9525 #[askama::filter_fn]
9530 pub fn commas<T: core::fmt::Display>(value: T, _: &dyn Values) -> Result<String> {
9531 Ok(super::group_thousands(&value.to_string()))
9532 }
9533}
9534
9535#[derive(Deserialize, Default)]
9536struct MultiCompareQuery {
9537 runs: Option<String>,
9538 scope: Option<String>,
9540 sub: Option<String>,
9542}
9543
9544#[allow(clippy::too_many_lines)]
9545async fn multi_compare_handler(
9546 State(state): State<AppState>,
9547 Query(params): Query<MultiCompareQuery>,
9548 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
9549) -> impl IntoResponse {
9550 let run_ids: Vec<String> = params
9551 .runs
9552 .as_deref()
9553 .unwrap_or("")
9554 .split(',')
9555 .map(|s| s.trim().to_string())
9556 .filter(|s| !s.is_empty())
9557 .collect();
9558
9559 if run_ids.len() < 2 {
9560 return Html(
9561 "<p style='font-family:sans-serif;padding:2rem'>At least 2 run IDs are required. \
9562 <a href=\"/compare-scans\">Go back</a></p>",
9563 )
9564 .into_response();
9565 }
9566 if run_ids.len() > 20 {
9567 return Html(
9568 "<p style='font-family:sans-serif;padding:2rem'>At most 20 scans can be compared \
9569 at once. <a href=\"/compare-scans\">Go back</a></p>",
9570 )
9571 .into_response();
9572 }
9573
9574 let entries: Vec<Option<RegistryEntry>> = {
9576 let reg = state.registry.lock().await;
9577 run_ids
9578 .iter()
9579 .map(|id| reg.entries.iter().find(|e| &e.run_id == id).cloned())
9580 .collect()
9581 };
9582
9583 for (i, entry) in entries.iter().enumerate() {
9584 if entry.is_none() {
9585 let html = format!(
9586 "<p style='font-family:sans-serif;padding:2rem'>Scan ID <code>{}</code> not \
9587 found. <a href=\"/compare-scans\">Go back</a></p>",
9588 run_ids[i]
9589 );
9590 return Html(html).into_response();
9591 }
9592 }
9593
9594 let mut entries: Vec<RegistryEntry> = entries.into_iter().flatten().collect();
9595
9596 for entry in &entries {
9597 if entry.json_path.is_none() {
9598 let html = format!(
9599 "<p style='font-family:sans-serif;padding:2rem'>Scan <code>{}</code> has no \
9600 JSON data — re-run the analysis to enable comparison. \
9601 <a href=\"/compare-scans\">Go back</a></p>",
9602 entry.run_id
9603 );
9604 return Html(html).into_response();
9605 }
9606 }
9607
9608 entries.sort_by_key(|e| e.timestamp_utc);
9610
9611 let mut runs: Vec<AnalysisRun> = Vec::with_capacity(entries.len());
9613 for entry in &entries {
9614 let path = entry.json_path.as_ref().unwrap();
9615 match read_json(path) {
9616 Ok(r) => runs.push(r),
9617 Err(e) => {
9618 let html = format!(
9619 "<p style='font-family:sans-serif;padding:2rem'>Could not load scan \
9620 <code>{}</code>: {e}. <a href=\"/compare-scans\">Go back</a></p>",
9621 entry.run_id
9622 );
9623 return Html(html).into_response();
9624 }
9625 }
9626 }
9627
9628 let all_sub_names: Vec<String> = {
9630 let mut set = std::collections::BTreeSet::new();
9631 for r in &runs {
9632 for s in &r.submodule_summaries {
9633 set.insert(s.name.clone());
9634 }
9635 }
9636 set.into_iter().collect()
9637 };
9638 let has_submodule_data = !all_sub_names.is_empty();
9639 let active_submodule = params.sub.clone();
9640 let super_scope_active = params.scope.as_deref() == Some("super");
9641
9642 apply_scope_filter(&mut runs, &active_submodule, super_scope_active);
9644
9645 let runs_csv = params.runs.as_deref().unwrap_or("").to_string();
9646 let project_label = entries
9647 .first()
9648 .map_or("", |e| e.project_label.as_str())
9649 .to_string();
9650 let run_refs: Vec<&AnalysisRun> = runs.iter().collect();
9651 let multi = compute_multi_delta(&run_refs);
9652 let html = multi_compare_page(
9653 &multi,
9654 &project_label,
9655 env!("CARGO_PKG_VERSION"),
9656 &csp_nonce,
9657 has_submodule_data,
9658 &all_sub_names,
9659 &runs_csv,
9660 super_scope_active,
9661 active_submodule.as_deref(),
9662 &entries,
9663 );
9664 (
9667 [(axum::http::header::CACHE_CONTROL, "no-store")],
9668 Html(html),
9669 )
9670 .into_response()
9671}
9672
9673const fn multi_delta_class(n: i64) -> &'static str {
9674 match n {
9675 1.. => "pos",
9676 ..=-1 => "neg",
9677 0 => "zero",
9678 }
9679}
9680
9681fn multi_fmt_delta(n: i64) -> String {
9682 if n > 0 {
9683 format!("+{n}")
9684 } else {
9685 format!("{n}")
9686 }
9687}
9688
9689fn js_escape(s: &str) -> String {
9691 use std::fmt::Write as _;
9692 let mut out = String::with_capacity(s.len() + 2);
9693 for c in s.chars() {
9694 match c {
9695 '"' => out.push_str("\\\""),
9696 '\\' => out.push_str("\\\\"),
9697 '\n' => out.push_str("\\n"),
9698 '\r' => out.push_str("\\r"),
9699 '\t' => out.push_str("\\t"),
9700 c if (c as u32) < 0x20 => {
9701 let _ = write!(out, "\\u{:04x}", c as u32);
9702 }
9703 c => out.push(c),
9704 }
9705 }
9706 out
9707}
9708
9709fn mc_entry_html_data(entries: &[RegistryEntry], idx: usize, run_id: &str) -> (String, String) {
9711 let Some(entry) = entries.get(idx).filter(|e| e.run_id == run_id) else {
9712 return (
9713 "—".to_string(),
9714 "<span class=\"mc-row-val\">—</span>".to_string(),
9715 );
9716 };
9717 let cd = entry
9718 .git_commit_date
9719 .as_deref()
9720 .and_then(fmt_git_date)
9721 .unwrap_or_else(|| "—".to_string());
9722 let au = entry.git_author.as_deref().map_or_else(
9723 || "<span class=\"mc-row-val\">—</span>".to_string(),
9724 |a| {
9725 format!(
9726 "<span class=\"mc-row-val\"><span class=\"cmp-author-val\">{}</span>\
9727 <span class=\"cmp-author-handle\"></span></span>",
9728 html_escape(a)
9729 )
9730 },
9731 );
9732 (cd, au)
9733}
9734
9735fn mc_scope_badge(active_sub: Option<&str>, super_scope_active: bool) -> String {
9737 active_sub.map_or_else(
9738 || {
9739 if super_scope_active {
9740 "<span class=\"mc-scope-tag mc-scope-super\">Super-repo only</span>".to_string()
9741 } else {
9742 "<span class=\"mc-scope-tag mc-scope-full\">\
9743 <svg width=\"9\" height=\"9\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\">\
9744 <circle cx=\"12\" cy=\"12\" r=\"10\"></circle>\
9745 <line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"></line>\
9746 <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>\
9747 </svg> Full scan</span>"
9748 .to_string()
9749 }
9750 },
9751 |s| format!("<span class=\"mc-scope-tag mc-scope-sub\">{}</span>", html_escape(s)),
9752 )
9753}
9754
9755fn build_mc_scan_strip(
9757 multi: &MultiScanComparison,
9758 entries: &[RegistryEntry],
9759 n: usize,
9760 is_many: bool,
9761 active_sub: Option<&str>,
9762 super_scope_active: bool,
9763 project_label: &str,
9764) -> String {
9765 use std::fmt::Write as _;
9766 let mut scan_strip = String::new();
9767 for (i, pt) in multi.points.iter().enumerate() {
9768 let ts_ms = pt.timestamp.timestamp_millis();
9769 let ts = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
9770 let commit = pt.git_commit.as_deref().unwrap_or("\u{2014}");
9771 let branch = pt.git_branch.as_deref().unwrap_or("");
9772 let report_link = format!("/runs/html/{}", pt.run_id);
9773 let branch_html = if branch.is_empty() {
9774 "<span class=\"mc-row-val\">—</span>".to_string()
9775 } else {
9776 format!(
9777 "<span class=\"mc-card-branch\">{}</span>",
9778 html_escape(branch)
9779 )
9780 };
9781 let (commit_date_html, author_html) = mc_entry_html_data(entries, i, &pt.run_id);
9782 let tags_html = pt
9783 .git_tags
9784 .as_deref()
9785 .filter(|t| !t.is_empty())
9786 .map(|t| {
9787 let chips = t
9788 .split(',')
9789 .filter(|s| !s.is_empty())
9790 .map(|tag| format!("<span class='mc-tag'>{}</span>", html_escape(tag)))
9791 .collect::<Vec<_>>()
9792 .join(" ");
9793 format!(
9794 "<div class=\"mc-card-row\"><span class=\"mc-row-label\">Tags:</span>\
9795 <span class=\"mc-row-val\">{chips}</span></div>"
9796 )
9797 })
9798 .unwrap_or_default();
9799 let nearest = pt
9800 .git_nearest_tag
9801 .as_deref()
9802 .map(|t| format!("near {}", html_escape(t)))
9803 .unwrap_or_default();
9804 let arrow = if i < n - 1 && !is_many {
9805 "<div class='mc-arrow'>→</div>"
9806 } else {
9807 ""
9808 };
9809 let scope_badge = mc_scope_badge(active_sub, super_scope_active);
9810 let nearest_html = if nearest.is_empty() {
9811 String::new()
9812 } else {
9813 format!(
9814 "<span class=\"mc-card-nearest-wrap\">\
9815 <span class=\"mc-card-nearest\">{nearest}</span>\
9816 <span class=\"mc-card-nearest-tip\">Nearest ancestor git release tag at scan time</span>\
9817 </span>"
9818 )
9819 };
9820 write!(
9821 scan_strip,
9822 r#"<div class="mc-card">
9823 <div class="mc-card-header">
9824 <div class="mc-card-num">Scan {num}</div>
9825 <div class="mc-card-project-col">
9826 <div class="mc-card-project">{project_label}</div>
9827 {scope_badge}
9828 </div>
9829 </div>
9830 <a class="mc-card-commit" href="{report_link}" target="_blank" title="View report">{commit}</a>
9831 <div class="mc-card-rows">
9832 <div class="mc-card-row"><span class="mc-row-label">Branch:</span>{branch_html}</div>
9833 <div class="mc-card-row"><span class="mc-row-label">Last commit on:</span><span class="mc-row-val">{commit_date}</span></div>
9834 <div class="mc-card-row"><span class="mc-row-label">Last commit by:</span>{author_html}</div>
9835 <div class="mc-card-row"><span class="mc-row-label">Scanned on:</span><span class="mc-row-val mc-ts-local" data-utc-ms="{ts_ms}">{ts}</span></div>
9836 {tags_html}
9837 </div>
9838 <div class="mc-card-code"><strong>{code} loc</strong>{nearest_html}</div>
9839 </div>{arrow}"#,
9840 num = i + 1,
9841 commit = html_escape(commit),
9842 commit_date = commit_date_html,
9843 ts_ms = ts_ms,
9844 code = fmt_num(pt.code_lines),
9845 scope_badge = scope_badge,
9846 nearest_html = nearest_html,
9847 )
9848 .unwrap();
9849 }
9850 scan_strip
9851}
9852
9853#[allow(clippy::too_many_lines)]
9855fn build_mc_metrics_table(multi: &MultiScanComparison, n: usize) -> (String, String) {
9856 use std::fmt::Write as _;
9857 struct MetricRow<'a> {
9858 label: &'a str,
9859 values: Vec<i64>,
9860 seq_deltas: Vec<i64>,
9861 net_delta: i64,
9862 }
9863 let rows: Vec<MetricRow<'_>> = vec![
9864 MetricRow {
9865 label: "Code Lines",
9866 values: multi.points.iter().map(|p| p.code_lines).collect(),
9867 seq_deltas: multi
9868 .sequential_deltas
9869 .iter()
9870 .map(|d| d.summary.code_lines_delta)
9871 .collect(),
9872 net_delta: multi.total_delta.code_lines_delta,
9873 },
9874 MetricRow {
9875 label: "Files Analyzed",
9876 values: multi.points.iter().map(|p| p.files_analyzed).collect(),
9877 seq_deltas: multi
9878 .sequential_deltas
9879 .iter()
9880 .map(|d| d.summary.files_analyzed_delta)
9881 .collect(),
9882 net_delta: multi.total_delta.files_analyzed_delta,
9883 },
9884 MetricRow {
9885 label: "Comment Lines",
9886 values: multi.points.iter().map(|p| p.comment_lines).collect(),
9887 seq_deltas: multi
9888 .sequential_deltas
9889 .iter()
9890 .map(|d| d.summary.comment_lines_delta)
9891 .collect(),
9892 net_delta: multi.total_delta.comment_lines_delta,
9893 },
9894 MetricRow {
9895 label: "Blank Lines",
9896 values: multi.points.iter().map(|p| p.blank_lines).collect(),
9897 seq_deltas: multi
9898 .sequential_deltas
9899 .iter()
9900 .map(|d| d.summary.blank_lines_delta)
9901 .collect(),
9902 net_delta: multi.total_delta.blank_lines_delta,
9903 },
9904 MetricRow {
9905 label: "Tests",
9906 values: multi.points.iter().map(|p| p.test_count).collect(),
9907 seq_deltas: multi
9908 .points
9909 .windows(2)
9910 .map(|pts| pts[1].test_count - pts[0].test_count)
9911 .collect(),
9912 net_delta: multi.points.last().map_or(0, |l| l.test_count)
9913 - multi.points.first().map_or(0, |f| f.test_count),
9914 },
9915 ];
9916 let mut metrics_thead = String::from("<tr><th class='mc-met-label'>Metric</th>");
9917 for i in 0..n {
9918 write!(metrics_thead, "<th class='mc-val-col'>Scan {}</th>", i + 1).unwrap();
9919 if i < n - 1 {
9920 metrics_thead.push_str("<th class='mc-delta-col'>→Δ</th>");
9921 }
9922 }
9923 metrics_thead.push_str("<th class='mc-net-col'>Net Δ</th></tr>");
9924 let mut metrics_tbody = String::new();
9925 for row in &rows {
9926 metrics_tbody.push_str("<tr>");
9927 write!(metrics_tbody, "<td class='mc-met-label'>{}</td>", row.label).unwrap();
9928 for i in 0..n {
9929 write!(
9930 metrics_tbody,
9931 "<td class='mc-val-col'>{}</td>",
9932 fmt_comma(row.values[i])
9933 )
9934 .unwrap();
9935 if i < n - 1 {
9936 let d = row.seq_deltas[i];
9937 write!(
9938 metrics_tbody,
9939 "<td class='mc-delta-col {cls}'>{val}</td>",
9940 cls = multi_delta_class(d),
9941 val = multi_fmt_delta(d)
9942 )
9943 .unwrap();
9944 }
9945 }
9946 let nd = row.net_delta;
9947 write!(
9948 metrics_tbody,
9949 "<td class='mc-net-col {cls}'>{val}</td>",
9950 cls = multi_delta_class(nd),
9951 val = multi_fmt_delta(nd)
9952 )
9953 .unwrap();
9954 metrics_tbody.push_str("</tr>");
9955 }
9956 (metrics_thead, metrics_tbody)
9957}
9958
9959fn build_mc_points_json(multi: &MultiScanComparison, entries: &[RegistryEntry]) -> String {
9961 let mut parts: Vec<String> = Vec::with_capacity(multi.points.len());
9962 for (i, pt) in multi.points.iter().enumerate() {
9963 let commit = pt.git_commit.as_deref().unwrap_or("");
9964 let branch = pt.git_branch.as_deref().unwrap_or("");
9965 let tags = pt.git_tags.as_deref().unwrap_or("");
9966 let nearest = pt.git_nearest_tag.as_deref().unwrap_or("");
9967 let scanned_ms = pt.timestamp.timestamp_millis();
9968 let scanned = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
9969 let entry = entries.get(i).filter(|e| e.run_id == pt.run_id);
9970 let commit_date = entry
9971 .and_then(|e| e.git_commit_date.as_deref())
9972 .and_then(fmt_git_date)
9973 .unwrap_or_default();
9974 let author = entry
9975 .and_then(|e| e.git_author.as_deref())
9976 .unwrap_or("")
9977 .to_string();
9978 let cov = pt
9979 .coverage_line_pct
9980 .map_or_else(|| "null".to_string(), |v| format!("{v:.1}"));
9981 parts.push(format!(
9982 r#"{{"run_id":"{run_id}","commit":"{commit}","branch":"{branch}","tags":"{tags}","nearest":"{nearest}","commit_date":"{commit_date}","author":"{author}","scanned":"{scanned}","scanned_ms":{scanned_ms},"code":{code},"comments":{comments},"blank":{blank},"files":{files},"tests":{tests},"cov":{cov}}}"#,
9983 run_id = js_escape(&pt.run_id),
9984 commit = js_escape(commit),
9985 branch = js_escape(branch),
9986 tags = js_escape(tags),
9987 nearest = js_escape(nearest),
9988 commit_date = js_escape(&commit_date),
9989 author = js_escape(&author),
9990 scanned = js_escape(&scanned),
9991 code = pt.code_lines,
9992 comments = pt.comment_lines,
9993 blank = pt.blank_lines,
9994 files = pt.files_analyzed,
9995 tests = pt.test_count,
9996 ));
9997 }
9998 format!("[{}]", parts.join(","))
9999}
10000
10001fn build_mc_file_matrix_json(multi: &MultiScanComparison) -> String {
10003 let mut parts: Vec<String> = Vec::with_capacity(multi.file_matrix.len());
10004 for row in &multi.file_matrix {
10005 let lang = row.language.as_deref().unwrap_or("");
10006 let codes: Vec<String> = row
10007 .code_per_scan
10008 .iter()
10009 .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
10010 .collect();
10011 let deltas: Vec<String> = row
10012 .code_delta_per_scan
10013 .iter()
10014 .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
10015 .collect();
10016 parts.push(format!(
10017 r#"{{"p":"{path}","l":"{lang}","s":"{status}","c":[{codes}],"d":[{deltas}],"t":{total}}}"#,
10018 path = row.relative_path.replace('\\', "/").replace('"', "\\\""),
10019 status = row.overall_status,
10020 codes = codes.join(","),
10021 deltas = deltas.join(","),
10022 total = row.total_code_delta,
10023 ));
10024 }
10025 format!("[{}]", parts.join(","))
10026}
10027
10028fn build_mc_file_col_headers(n: usize) -> String {
10030 use std::fmt::Write as _;
10031 let mut out = String::new();
10032 for i in 0..n {
10033 write!(out, "<th class='file-scan-col'>Scan {} Code</th>", i + 1).unwrap();
10034 if i < n - 1 {
10035 write!(
10036 out,
10037 "<th class='file-delta-col'>Δ→{}</th>",
10038 i + 2
10039 )
10040 .unwrap();
10041 }
10042 }
10043 out
10044}
10045
10046fn build_mc_scope_bar(
10048 has_submodule_data: bool,
10049 sub_names: &[String],
10050 runs_csv: &str,
10051 active_sub: Option<&str>,
10052 super_scope_active: bool,
10053) -> String {
10054 use std::fmt::Write as _;
10055 if !has_submodule_data {
10056 return String::new();
10057 }
10058 let base_url = format!("/multi-compare?runs={}", html_escape(runs_csv));
10059 let full_active = active_sub.is_none() && !super_scope_active;
10060 let mut bar = format!(
10061 r#"<div class="submod-scope-bar">
10062 <span class="submod-scope-label">
10063 <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>
10064 Scope:
10065 </span>
10066 <div class="submod-scope-divider"></div>
10067 <a class="submod-scope-btn{full_cls}" href="{base_url}" title="All files — super-repo and all submodules combined">Full scan</a>
10068 <a class="submod-scope-btn{super_cls}" href="{base_url}&scope=super" title="Only files not belonging to any submodule">Super-repo only</a>"#,
10069 full_cls = if full_active { " active" } else { "" },
10070 super_cls = if super_scope_active { " active" } else { "" },
10071 );
10072 for s in sub_names {
10073 let is_active = active_sub == Some(s.as_str());
10074 write!(
10075 bar,
10076 "\n <a class=\"submod-scope-btn{cls}\" href=\"{base_url}&sub={name_enc}\" title=\"Only files in submodule {name_esc}\">{name_esc}</a>",
10077 cls = if is_active { " active" } else { "" },
10078 name_enc = html_escape(s),
10079 name_esc = html_escape(s),
10080 )
10081 .unwrap();
10082 }
10083 bar.push_str("\n</div>");
10084 bar
10085}
10086
10087fn build_mc_scope_label(active_sub: Option<&str>, super_scope_active: bool) -> String {
10089 active_sub.map_or_else(
10090 || {
10091 if super_scope_active {
10092 "Super-repo only — ".to_string()
10093 } else {
10094 String::new()
10095 }
10096 },
10097 |s| format!("Submodule: {} — ", html_escape(s)),
10098 )
10099}
10100
10101#[allow(clippy::too_many_lines)]
10102#[allow(clippy::too_many_arguments)]
10103fn multi_compare_page(
10104 multi: &MultiScanComparison,
10105 project_label: &str,
10106 version: &str,
10107 csp_nonce: &str,
10108 has_submodule_data: bool,
10109 sub_names: &[String],
10110 runs_csv: &str,
10111 super_scope_active: bool,
10112 active_sub: Option<&str>,
10113 entries: &[RegistryEntry],
10114) -> String {
10115 let n = multi.points.len();
10116 let is_many = n > 4;
10117 let mc_strip_class = if is_many {
10118 "mc-strip mc-strip-grid"
10119 } else {
10120 "mc-strip"
10121 };
10122
10123 let scan_strip = build_mc_scan_strip(
10125 multi,
10126 entries,
10127 n,
10128 is_many,
10129 active_sub,
10130 super_scope_active,
10131 project_label,
10132 );
10133
10134 let (metrics_thead, metrics_tbody) = build_mc_metrics_table(multi, n);
10136
10137 let points_json = build_mc_points_json(multi, entries);
10139 let file_matrix_json = build_mc_file_matrix_json(multi);
10140
10141 let files_modified = multi
10143 .file_matrix
10144 .iter()
10145 .filter(|f| f.overall_status == "modified")
10146 .count();
10147 let files_added = multi
10148 .file_matrix
10149 .iter()
10150 .filter(|f| f.overall_status == "added")
10151 .count();
10152 let files_removed = multi
10153 .file_matrix
10154 .iter()
10155 .filter(|f| f.overall_status == "removed")
10156 .count();
10157 let files_unchanged = multi
10158 .file_matrix
10159 .iter()
10160 .filter(|f| f.overall_status == "unchanged")
10161 .count();
10162 let total_files = multi.file_matrix.len();
10163
10164 let file_col_headers = build_mc_file_col_headers(n);
10165 let nav_compare_active = "style=\"background:rgba(255,255,255,0.22);\"";
10166 let scope_bar_html = build_mc_scope_bar(
10167 has_submodule_data,
10168 sub_names,
10169 runs_csv,
10170 active_sub,
10171 super_scope_active,
10172 );
10173 let scope_label = build_mc_scope_label(active_sub, super_scope_active);
10174 let toast_assets = sloc_toast_assets(csp_nonce);
10175
10176 format!(
10177 r#"<!doctype html>
10178<html lang="en">
10179<head>
10180 <meta charset="utf-8">
10181 <meta name="viewport" content="width=device-width, initial-scale=1">
10182 <title>OxideSLOC | Multi-Scan Timeline — {project_label}</title>
10183 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
10184 <style nonce="{csp_nonce}">
10185 :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;--line:#e6d0bf;--line-strong:#d8bfad;--text:#43342d;--muted:#7b675b;--muted-2:#a08777;--nav:#283790;--nav-2:#013e6b;--accent:#6f9bff;--oxide:#d37a4c;--oxide-2:#b35428;--shadow:0 18px 42px rgba(77,44,20,0.12);--pos:#1a8f47;--pos-bg:#e8f5ed;--neg:#b33b3b;--neg-bg:#fcd6d6;}}
10186 *,*::before,*::after{{box-sizing:border-box;margin:0;padding:0;}}
10187 body{{background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,sans-serif;min-height:100vh;}}
10188 body.dark-theme{{--bg:#1a120b;--surface:#241a12;--surface-2:#2d2117;--line:#3d2e22;--line-strong:#54402f;--text:#f0e6dc;--muted:#b09080;--muted-2:#8a6e5f;--pos-bg:#163a23;--neg-bg:#3d1c1c;}}
10189 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
10190 .background-watermarks img{{position:absolute;opacity:0.15;filter:blur(0.3px);user-select:none;max-width:none;}}
10191 .code-particles{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
10192 .code-particle{{position:absolute;font-family:ui-monospace,monospace;font-size:11px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}}
10193 @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));}}}}
10194 .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);}}
10195 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;flex-wrap:nowrap;}}
10196 @media(max-width:1920px){{.top-nav-inner{{max-width:1500px;}}.page{{max-width:1500px;}}}}
10197 @media(max-width:1400px){{.nav-right{{gap:6px;}}.nav-pill,.nav-dropdown-btn,.theme-toggle{{padding:0 10px;}}}}
10198 @media(max-width:1150px){{.nav-right{{gap:4px;}}.nav-pill,.nav-dropdown-btn,.theme-toggle{{padding:0 8px;font-size:11px;min-height:34px;}}.brand-subtitle{{display:none;}}}}
10199 .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}
10200 .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));}}
10201 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
10202 .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}
10203 .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}
10204 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}}
10205 .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;white-space:nowrap;text-decoration:none;transition:background .15s ease,transform .15s ease;}}
10206 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
10207 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}}
10208 .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
10209 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
10210 .nav-dropdown{{position:relative;display:inline-flex;}}
10211 .nav-dropdown-btn{{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;white-space:nowrap;text-decoration:none;cursor:pointer;transition:background .15s ease,transform .15s ease;}}
10212 .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
10213 .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 .13s,visibility 0s .13s;}}
10214 .nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{{opacity:1;visibility:visible;transition:opacity .13s,visibility 0s;}}
10215 .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);}}
10216 .nav-dropdown-menu a:last-child{{border-bottom:none;}}
10217 .nav-dropdown-menu a:hover{{background:rgba(255,255,255,0.14);color:#fff;}}
10218 .nav-dropdown-menu a svg{{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}}
10219 body:not(.dark-theme) .icon-sun{{display:none;}}
10220 body.dark-theme .icon-moon{{display:none;}}
10221 .settings-modal{{position:fixed;z-index:9999;background:var(--surface-2);border:1px solid var(--line-strong);border-radius:14px;box-shadow:0 12px 36px rgba(0,0,0,0.22);min-width:260px;max-width:320px;opacity:0;pointer-events:none;transform:translateY(-8px) scale(0.97);transition:opacity 0.18s ease,transform 0.18s ease;overflow:hidden;}}
10222 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
10223 .settings-modal-header{{display:flex;align-items:center;justify-content:space-between;padding:14px 16px 10px;border-bottom:1px solid var(--line);font-size:13px;font-weight:800;color:var(--text);}}
10224 .settings-close{{background:none;border:none;cursor:pointer;width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--muted);border-radius:6px;padding:0;}}
10225 .settings-close:hover{{color:var(--text);background:var(--surface-2);}}
10226 .settings-close svg{{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}}
10227 .settings-modal-body{{padding:14px 16px 16px;}}
10228 .settings-modal-label{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}}
10229 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
10230 .scheme-swatch{{display:flex;flex-direction:column;align-items:center;gap:5px;background:none;border:1.5px solid var(--line);border-radius:10px;cursor:pointer;padding:7px 4px 6px;transition:border-color 0.15s ease,transform 0.12s ease;}}
10231 .scheme-swatch:hover{{border-color:var(--line-strong);transform:translateY(-1px);}}
10232 .scheme-swatch.active{{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}}
10233 .scheme-preview{{width:28px;height:28px;border-radius:7px;flex-shrink:0;}}
10234 .scheme-label{{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}}
10235 .tz-select{{width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2);color:var(--text);font-size:12px;font-weight:600;cursor:pointer;outline:none;box-sizing:border-box;}}
10236 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
10237 .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;white-space:nowrap;margin-bottom:16px;}}
10238 .btn-back:hover{{background:var(--line);}}
10239 .mc-title{{font-size:28px;font-weight:900;letter-spacing:-.03em;margin:0 0 6px;background:linear-gradient(90deg,#b85d33 0%,#d37a4c 40%,#6f9bff 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;}}
10240 body.dark-theme .mc-title{{background:linear-gradient(90deg,#f0a070 0%,#d37a4c 40%,#9bb8ff 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;}}
10241 .mc-desc{{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}}
10242 .mc-subtitle{{font-size:14px;color:var(--muted);margin:0 0 6px;}}
10243 .mc-strip{{display:flex;align-items:stretch;flex-wrap:wrap;gap:12px;overflow:visible;padding:8px 4px 6px;margin-bottom:20px;width:100%;}}
10244 .mc-strip.mc-strip-grid{{display:grid!important;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:14px;overflow:visible;padding:8px 4px 6px;}}
10245 .mc-hero{{background:linear-gradient(180deg,rgba(255,255,255,0.18),transparent),var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px 24px 24px;margin-bottom:18px;}}
10246 .mc-hero-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:16px;flex-wrap:wrap;}}
10247 .mc-card{{background:var(--surface);border:1.5px solid var(--oxide);border-radius:14px;padding:16px 18px;flex:1 1 0;min-width:0;min-height:160px;display:flex;flex-direction:column;justify-content:flex-start;transition:box-shadow .15s ease,transform .12s ease;overflow:visible;position:relative;}}
10248 .mc-card:hover{{box-shadow:0 10px 28px rgba(77,44,20,0.18);}}
10249 body.dark-theme .mc-card{{background:var(--surface-2);}}
10250 .mc-card-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:10px;}}
10251 .mc-card-num{{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);}}
10252 .mc-card-project{{font-size:12px;font-weight:600;color:var(--muted);font-style:italic;text-align:right;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%;}}
10253 .mc-card-commit{{display:block;font-family:ui-monospace,monospace;font-size:24px;font-weight:800;letter-spacing:-0.02em;line-height:1.1;color:var(--accent);text-decoration:none;margin-bottom:14px;word-break:break-all;}}
10254 .mc-card-commit:hover{{color:var(--oxide);}}
10255 .mc-card-rows{{display:flex;flex-direction:column;gap:6px;}}
10256 .mc-card-row{{display:flex;align-items:baseline;gap:8px;font-size:13px;}}
10257 .mc-row-label{{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}}
10258 .mc-row-val{{color:var(--text);font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;}}
10259 .mc-card-branch{{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);font-weight:700;display:inline-block;}}
10260 .mc-tag{{font-size:10px;background:rgba(211,122,76,0.12);border:1px solid rgba(211,122,76,0.28);border-radius:4px;padding:1px 6px;color:var(--oxide);font-weight:700;margin-right:3px;display:inline-block;}}
10261 .mc-card-project-col{{display:flex;flex-direction:column;align-items:flex-end;gap:5px;max-width:72%;}}
10262 .mc-scope-tag{{display:inline-flex;align-items:center;gap:4px;font-size:10px;font-weight:800;padding:2px 8px;border-radius:5px;white-space:nowrap;letter-spacing:.03em;text-transform:uppercase;}}
10263 .mc-scope-full{{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}}
10264 .mc-scope-sub{{background:rgba(111,155,255,0.10);border:1px solid rgba(111,155,255,0.28);color:var(--accent);}}
10265 .mc-scope-super{{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.28);color:var(--oxide);}}
10266 .mc-card-nearest-wrap{{position:relative;display:inline-flex;align-items:center;gap:4px;cursor:default;}}
10267 .mc-card-nearest{{font-size:10px;color:var(--muted-2);font-style:italic;}}
10268 .mc-card-nearest-tip{{display:none;position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);background:rgba(20,12,8,0.97);color:rgba(255,255,255,0.92);border-radius:8px;padding:6px 10px;font-size:11px;font-weight:500;line-height:1.5;white-space:nowrap;box-shadow:0 4px 12px rgba(0,0,0,0.28);pointer-events:none;z-index:200;border:1px solid rgba(255,255,255,0.10);}}
10269 .mc-card-nearest-tip::after{{content:'';position:absolute;top:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-top-color:rgba(20,12,8,0.97);}}
10270 .mc-card-nearest-wrap:hover .mc-card-nearest-tip{{display:block;}}
10271 .mc-card-code{{font-size:15px;font-weight:800;color:var(--text);margin-top:12px;padding-top:10px;border-top:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;gap:6px;flex-wrap:nowrap;}}
10272 .cmp-author-handle{{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}}
10273 .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:0 0 16px;}}
10274 .submod-scope-divider{{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}}
10275 .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;}}
10276 .submod-scope-label svg{{stroke:currentColor;fill:none;stroke-width:2;}}
10277 .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,border-color .12s,color .12s;}}
10278 .submod-scope-btn:hover{{background:var(--line);}}
10279 .submod-scope-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10280 .mc-arrow{{font-size:22px;color:var(--muted);align-self:center;padding:0 4px;flex-shrink:0;}}
10281 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px 24px;margin-bottom:18px;position:relative;}}
10282 .panel-title{{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}}
10283 .metrics-table{{width:100%;border-collapse:collapse;font-size:13px;}}
10284 .metrics-table th,.metrics-table td{{padding:9px 12px;border-bottom:1px solid var(--line);text-align:right;}}
10285 .metrics-table th{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);background:var(--surface-2);}}
10286 .metrics-table td.mc-met-label,.metrics-table th.mc-met-label{{text-align:left;font-weight:700;color:var(--text);}}
10287 .metrics-table .mc-val-col{{font-weight:700;font-variant-numeric:tabular-nums;}}
10288 .metrics-table .mc-delta-col{{font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;}}
10289 .metrics-table .mc-net-col{{font-weight:800;font-size:13px;font-variant-numeric:tabular-nums;background:rgba(111,155,255,0.06);}}
10290 .metrics-table .pos{{color:var(--pos);}}
10291 .metrics-table .neg{{color:var(--neg);}}
10292 .metrics-table .zero{{color:var(--muted);}}
10293 .metrics-table tr:hover td{{background:rgba(211,122,76,0.04);}}
10294 .chart-toolbar{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
10295 .chart-metric-btn{{padding:5px 13px;border-radius:7px;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;transition:background .12s;}}
10296 .chart-metric-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10297 .chart-metric-btn:hover:not(.active){{background:var(--line);}}
10298 .chart-wrap{{width:100%;overflow-x:auto;}}
10299 #mc-chart{{display:block;width:100%;}}
10300 h2,.mc-charts-h2{{font-size:14px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 14px;}}
10301 .export-group{{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:4px;}}
10302 .ic-grid{{display:grid;grid-template-columns:1fr 1fr;gap:18px;}}
10303 @media(max-width:800px){{.ic-grid{{grid-template-columns:1fr;}}}}
10304 .ic-card{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
10305 body.dark-theme .ic-card{{background:var(--surface);border-color:var(--line-strong);}}
10306 .ic-card-h2{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin:0;}}
10307 .ic-card-h2-row{{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:12px;flex-wrap:wrap;}}
10308 .ic-card-h2-row .ic-card-h2{{margin:0;}}
10309 .ic-chart-hdr{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
10310 .ic-expand-btn{{background:none;border:1px solid var(--line-strong);border-radius:6px;cursor:pointer;color:var(--muted);padding:4px 10px;font-size:12px;line-height:1;transition:background .13s,color .13s;flex-shrink:0;white-space:nowrap;}}
10311 .ic-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
10312 .ic-svg-modal-ov{{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.58);z-index:9998;align-items:center;justify-content:center;padding:24px;box-sizing:border-box;}}
10313 .ic-svg-modal-ov.open{{display:flex;}}
10314 .ic-svg-modal{{background:var(--surface);border:1px solid var(--line-strong);border-radius:14px;padding:22px 24px;max-width:900px;width:100%;max-height:88vh;overflow-y:auto;position:relative;box-shadow:0 24px 80px rgba(0,0,0,0.3);}}
10315 .ic-svg-modal-hdr{{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;padding-bottom:12px;border-bottom:1px solid var(--line);}}
10316 .ic-svg-modal-title{{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}}
10317 .ic-svg-modal-close{{background:var(--surface-2);border:1px solid var(--line);border-radius:7px;padding:5px 11px;cursor:pointer;color:var(--text);font-size:12px;font-weight:700;}}
10318 .ic-svg-modal-close:hover{{background:var(--line);}}
10319 .ic-leg{{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}}
10320 .ic-dot{{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}}
10321 .ic-cb{{cursor:pointer;transition:opacity .17s,filter .17s,transform .17s;transform-box:fill-box;transform-origin:center center;}}
10322 .ic-cb:hover{{filter:brightness(1.15) drop-shadow(0 2px 6px rgba(0,0,0,.18));transform:scale(1.05);}}
10323 .ic-leg-item{{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}}
10324 .ic-leg-item:hover{{background:rgba(211,122,76,0.08);}}
10325 #mc-ic-tt{{display:none;position:fixed;background:rgba(15,10,6,.95);color:rgba(255,255,255,0.92);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);max-width:240px;white-space:nowrap;}}
10326 .filter-tabs-row{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
10327 .delta-note{{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}}
10328 .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;}}
10329 .tab-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10330 .tab-btn:hover:not(.active){{background:var(--line);}}
10331 .tab-btn.tab-modified{{background:#fff2d8;color:#926000;border-color:#e6c96c;}}
10332 .tab-btn.tab-modified.active{{background:#926000;border-color:#926000;color:#fff;}}
10333 .tab-btn.tab-added{{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}}
10334 .tab-btn.tab-added.active{{background:#1a8f47;border-color:#1a8f47;color:#fff;}}
10335 .tab-btn.tab-removed{{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}}
10336 .tab-btn.tab-removed.active{{background:#b33b3b;border-color:#b33b3b;color:#fff;}}
10337 body.dark-theme .tab-btn.tab-modified{{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}}
10338 body.dark-theme .tab-btn.tab-added{{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}}
10339 body.dark-theme .tab-btn.tab-removed{{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}}
10340 .table-wrap{{width:100%;overflow-x:auto;}}
10341 #file-table{{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}}
10342 #file-table th,#file-table td{{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap;}}
10343 #file-table th{{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);background:var(--surface-2);text-align:right;}}
10344 #file-table th.left,#file-table td.left{{text-align:left;}}
10345 .file-scan-col,.file-delta-col,.file-net-col{{text-align:right;font-variant-numeric:tabular-nums;font-weight:600;}}
10346 .file-delta-col{{color:var(--muted);font-size:11px;}}
10347 .file-net-col{{font-weight:800;}}
10348 .pos{{color:var(--pos);}} .neg{{color:var(--neg);}} .zero{{color:var(--muted);}}
10349 #file-table th.sortable{{cursor:pointer;user-select:none;}} #file-table th.sortable:hover{{color:var(--oxide);}}
10350 #file-table .sort-icon{{margin-left:3px;font-size:9px;opacity:.4;vertical-align:middle;}}
10351 #file-table th.sort-asc .sort-icon,#file-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
10352 .status-badge{{padding:2px 7px;border-radius:4px;font-size:10px;font-weight:700;text-transform:uppercase;}}
10353 .status-badge.modified{{background:#fff2d8;color:#926000;}}
10354 .status-badge.added{{background:#e8f5ed;color:#1a8f47;}}
10355 .status-badge.removed{{background:#fdeaea;color:#b33b3b;}}
10356 .status-badge.unchanged{{background:var(--surface-2);color:var(--muted);}}
10357 body.dark-theme .status-badge.modified{{background:#3d2f0a;color:#f0c060;}}
10358 body.dark-theme .status-badge.added{{background:#163927;color:#8fe2a8;}}
10359 body.dark-theme .status-badge.removed{{background:#3d1c1c;color:#f5a3a3;}}
10360 tr.row-added td{{background:rgba(26,143,71,0.04);}}
10361 tr.row-removed td{{background:rgba(179,59,59,0.06);}}
10362 tr.row-modified td{{background:rgba(146,96,0,0.04);}}
10363 tr.row-unchanged td{{color:var(--muted);}}
10364 tr.row-unchanged .status-badge{{opacity:.65;}}
10365 .file-path{{font-family:ui-monospace,monospace;font-size:11px;max-width:340px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block;vertical-align:middle;}}
10366 .absent{{color:var(--muted);font-style:italic;}}
10367 .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
10368 .pagination-info{{font-size:12px;color:var(--muted);}}
10369 .pagination-btns{{display:flex;gap:5px;}}
10370 .pg-btn{{min-width:32px;min-height:32px;display:inline-flex;align-items:center;justify-content:center;border-radius:7px;border:1px solid var(--line);background:var(--surface-2);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;transition:background .12s;}}
10371 .pg-btn:hover:not(:disabled){{background:var(--line);}}
10372 .pg-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10373 .pg-btn:disabled{{opacity:.35;cursor:default;}}
10374 select.per-page{{border:1px solid var(--line-strong);border-radius:7px;background:var(--surface-2);color:var(--text);padding:4px 9px;font-size:12px;cursor:pointer;}}
10375 .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;}}
10376 .export-btn:hover{{background:var(--line);}}
10377 .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{{display:block;}}.status-dot{{display:inline-block;width:8px;height:8px;border-radius:50%;background:#26d768;box-shadow:0 0 0 3px rgba(38,215,104,0.18);flex-shrink:0;}}
10378 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
10379 .site-footer a{{color:var(--muted);}}
10380 body.pdf-mode .top-nav,body.pdf-mode .background-watermarks,body.pdf-mode #code-particles,body.pdf-mode .export-group,body.pdf-mode .btn-back,body.pdf-mode .chart-toolbar,body.pdf-mode .filter-tabs-row,body.pdf-mode .filter-tabs,body.pdf-mode .pagination,body.pdf-mode select.per-page,body.pdf-mode .submod-scope-bar,body.pdf-mode .settings-modal,body.pdf-mode .site-footer{{display:none!important;}}
10381 body.pdf-mode{{background:#fff!important;}}
10382 body.pdf-mode .page{{padding:4px 6px 4px!important;}}
10383 .mc-modal-overlay{{position:fixed;inset:0;z-index:8000;background:rgba(0,0,0,0.52);display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:opacity .18s ease;}}
10384 .mc-modal-overlay.open{{opacity:1;pointer-events:auto;}}
10385 .mc-modal{{background:var(--surface);border:1px solid var(--line-strong);border-radius:16px;box-shadow:0 24px 64px rgba(0,0,0,0.28);max-width:1000px;width:94%;max-height:86vh;overflow-y:auto;position:relative;}}
10386 .mc-modal-head{{background:var(--nav);color:#fff;padding:16px 20px;border-radius:14px 14px 0 0;display:flex;justify-content:space-between;align-items:flex-start;gap:12px;}}
10387 .mc-modal-title{{font-size:18px;font-weight:800;}}
10388 .mc-modal-sub{{font-size:12px;opacity:.72;margin-top:3px;word-break:break-all;}}
10389 .mc-modal-close{{background:rgba(255,255,255,0.18);border:none;color:#fff;width:28px;height:28px;border-radius:50%;cursor:pointer;font-size:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}}
10390 .mc-modal-close:hover{{background:rgba(255,255,255,0.32);}}
10391 .mc-modal-body{{padding:18px 22px;}}
10392 .mc-modal-sec{{margin-bottom:20px;}}
10393 .mc-modal-sec-title{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:10px;}}
10394 .mc-modal-stats{{display:flex;flex-wrap:nowrap;gap:8px;margin-bottom:8px;}}
10395 .mc-modal-stat{{flex:1 1 0;min-width:0;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:10px 12px;cursor:default;transition:transform .15s ease,box-shadow .15s ease,border-color .15s ease;}}
10396 .mc-modal-stat:hover{{transform:translateY(-3px);box-shadow:0 8px 22px rgba(196,92,16,0.20);border-color:var(--oxide);}}
10397 .mc-modal-stat-val{{font-size:17px;font-weight:900;color:var(--oxide);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}
10398 .mc-modal-stat-lbl{{font-size:10px;font-weight:700;text-transform:uppercase;color:var(--muted);letter-spacing:.05em;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}
10399 .mc-modal-row{{display:flex;gap:14px;font-size:14px;padding:9px 0;border-bottom:1px solid var(--line);align-items:baseline;}}
10400 .mc-modal-row:last-child{{border-bottom:none;}}
10401 .mc-modal-key{{color:var(--muted);font-weight:700;font-size:12px;text-transform:uppercase;letter-spacing:.04em;flex-shrink:0;min-width:160px;}}
10402 .mc-modal-val{{color:var(--text);font-size:14.5px;font-weight:600;word-break:break-all;}}
10403 .mc-modal-val a{{color:var(--oxide);text-decoration:none;font-weight:700;}}
10404 .mc-modal-val a:hover{{text-decoration:underline;}}
10405 body.dark-theme .mc-modal-stat{{background:rgba(255,255,255,0.07);}}
10406 body.dark-theme .mc-modal-stat:hover{{box-shadow:0 8px 22px rgba(0,0,0,0.40);}}
10407 .mc-modal-stat[data-tip]{{cursor:help;}}
10408 #mc-stat-tt{{display:none;position:fixed;background:rgba(15,10,6,0.96);color:rgba(255,255,255,0.94);border-radius:8px;padding:9px 13px;font-size:12.5px;font-weight:500;line-height:1.5;pointer-events:none;z-index:9001;box-shadow:0 6px 22px rgba(0,0,0,0.34);max-width:300px;border:1px solid rgba(255,255,255,0.12);}}
10409 .mc-card{{cursor:pointer;}}
10410 .mc-card:hover{{transform:translateY(-4px);box-shadow:0 10px 28px rgba(196,92,16,0.24);z-index:10;}}
10411 </style>
10412</head>
10413<body>
10414 {loading_overlay}
10415 <div class="background-watermarks" aria-hidden="true">
10416 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10417 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10418 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10419 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10420 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10421 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10422 </div>
10423 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
10424 <div class="top-nav">
10425 <div class="top-nav-inner">
10426 <a class="brand" href="/">
10427 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
10428 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Multi-Scan Timeline</div></div>
10429 </a>
10430 <div class="nav-right">
10431 <a class="nav-pill" href="/">Home</a>
10432 <div class="nav-dropdown">
10433 <a href="/view-reports" class="nav-dropdown-btn">View Reports <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></a>
10434 <div class="nav-dropdown-menu">
10435 <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
10436 </div>
10437 </div>
10438 <a class="nav-pill" href="/compare-scans" {nav_compare_active}>Compare Scans</a>
10439 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
10440 <div class="nav-dropdown">
10441 <a href="/git-browser" class="nav-dropdown-btn">Git Browser <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></a>
10442 <div class="nav-dropdown-menu">
10443 <a href="/integrations"><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>Integrations</a>
10444 </div>
10445 </div>
10446 <div class="server-status-wrap" id="server-status-wrap">
10447 <div class="nav-pill server-online-pill" id="server-status-pill">
10448 <span class="status-dot" id="status-dot"></span>
10449 <span id="server-status-label">Server</span>
10450 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
10451 </div>
10452 <div class="server-status-tip">
10453 OxideSLOC is running — accessible on your network.
10454 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
10455 </div>
10456 </div>
10457 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
10458 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
10459 </button>
10460 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
10461 <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>
10462 <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>
10463 </button>
10464 </div>
10465 </div>
10466 </div>
10467
10468 <div class="page">
10469 <!-- Hero header -->
10470 <div class="mc-hero">
10471 <div class="mc-hero-header">
10472 <div>
10473 <div class="mc-title">Multi-Scan Timeline</div>
10474 <p class="mc-desc">Side-by-side metric comparison across multiple scans — code line progression, file changes, and language breakdown.</p>
10475 <div class="mc-subtitle">{scope_label}{n} scans · project: <strong>{project_label}</strong></div>
10476 </div>
10477 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
10478 <a class="btn-back" href="/compare-scans"><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> Compare Scans</a>
10479 <div class="export-group" id="mc-top-export-group">
10480 <button type="button" class="export-btn" id="mc-top-export-html-btn" title="Export this page as a standalone HTML report"><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> Export HTML</button>
10481 <button type="button" class="export-btn" id="mc-top-export-pdf-btn" title="Export this page as a PDF report"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg> Export PDF</button>
10482 </div>
10483 </div>
10484 </div>
10485 {scope_bar_html}
10486 <!-- Scan strip -->
10487 <div class="{mc_strip_class}">{scan_strip}</div>
10488 </div>
10489
10490 <!-- Summary metrics table -->
10491 <div class="panel">
10492 <div class="panel-title">Metric Progression</div>
10493 <div class="table-wrap">
10494 <table class="metrics-table">
10495 <thead>{metrics_thead}</thead>
10496 <tbody>{metrics_tbody}</tbody>
10497 </table>
10498 </div>
10499 </div>
10500
10501 <!-- Scan Charts -->
10502 <div class="panel" id="mc-charts-panel">
10503 <div class="panel-title" style="margin-bottom:14px;">Scan Delta Charts</div>
10504 <div class="ic-grid">
10505 <!-- Timeline line chart — spans full width -->
10506 <div class="ic-card" style="grid-column:span 2">
10507 <div class="ic-card-h2-row">
10508 <span class="ic-card-h2">Timeline</span>
10509 <div class="chart-toolbar" style="margin:0">
10510 <button class="chart-metric-btn active" data-metric="code">Code Lines</button>
10511 <button class="chart-metric-btn" data-metric="files">Files</button>
10512 <button class="chart-metric-btn" data-metric="comments">Comments</button>
10513 <button class="chart-metric-btn" data-metric="tests">Tests</button>
10514 <button class="chart-metric-btn" data-metric="cov">Coverage</button>
10515 </div>
10516 </div>
10517 <div class="chart-wrap"><svg id="mc-chart" height="280"></svg></div>
10518 </div>
10519 <!-- Code Metrics: Scan 1 vs Latest -->
10520 <div class="ic-card">
10521 <div class="ic-chart-hdr"><span class="ic-card-h2">Code Metrics — Scan 1 vs Latest</span><button class="ic-expand-btn" data-expand-src="mc-ic-c1" data-expand-title="Code Metrics — Scan 1 vs Latest">⤢ Full View</button></div>
10522 <div class="ic-leg"><span class="ic-leg-item" data-highlight="Code Lines"><span class="ic-dot" style="background:#E3A876"></span><span style="color:#C45C10;font-weight:600">Code Lines</span></span><span class="ic-leg-item" data-highlight="Files"><span class="ic-dot" style="background:#9FC3AE"></span><span style="color:#2A6846;font-weight:600">Files</span></span><span class="ic-leg-item" data-highlight="Comments"><span class="ic-dot" style="background:#E0C58A"></span><span style="color:#BE8A2E;font-weight:600">Comments</span></span><span style="font-size:10px;color:var(--muted)">(faded = scan 1)</span></div>
10523 <div id="mc-ic-c1"></div>
10524 </div>
10525 <!-- Language Code Delta -->
10526 <div class="ic-card" id="mc-ic-lang-card">
10527 <div class="ic-chart-hdr"><span class="ic-card-h2">Language Code Delta</span><button class="ic-expand-btn" data-expand-src="mc-ic-c3" data-expand-title="Language Code Delta">⤢ Full View</button></div>
10528 <div style="font-size:10.5px;color:var(--muted);margin:-4px 0 12px;line-height:1.45;">Net change in <strong>code lines</strong> per language from the first to the latest scan (<strong>+0</strong> means that language is unchanged). The count on the right is how many <strong>files</strong> of that language were scanned.</div>
10529 <div id="mc-ic-c3"></div>
10530 </div>
10531 <!-- Delta by Metric -->
10532 <div class="ic-card">
10533 <div class="ic-chart-hdr"><span class="ic-card-h2">Delta by Metric</span><button class="ic-expand-btn" data-expand-src="mc-ic-c2" data-expand-title="Delta by Metric">⤢ Full View</button></div>
10534 <div id="mc-ic-c2"></div>
10535 </div>
10536 <!-- File Change Distribution -->
10537 <div class="ic-card">
10538 <div class="ic-chart-hdr"><span class="ic-card-h2">File Change Distribution</span><button class="ic-expand-btn" data-expand-src="mc-ic-c4" data-expand-title="File Change Distribution">⤢ Full View</button></div>
10539 <div id="mc-ic-c4"></div>
10540 </div>
10541 </div>
10542 </div>
10543
10544 <!-- File matrix table -->
10545 <div class="panel">
10546 <div class="panel-title">File Matrix <span style="font-size:11px;font-weight:400;color:var(--muted);margin-left:8px;text-transform:none;letter-spacing:0;">{total_files} files</span></div>
10547 <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
10548 <div class="filter-tabs-row" style="margin-bottom:0;gap:6px;">
10549 <button class="tab-btn tab-all active" data-status="">All ({total_files})</button>
10550 <button class="tab-btn tab-modified" data-status="modified">Modified ({files_modified})</button>
10551 <button class="tab-btn tab-added" data-status="added">Added ({files_added})</button>
10552 <button class="tab-btn tab-removed" data-status="removed">Removed ({files_removed})</button>
10553 <button class="tab-btn tab-unchanged" data-status="unchanged">Unchanged ({files_unchanged})</button>
10554 </div>
10555 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
10556 <span class="delta-note">* Δ = delta (change from scan 1 → latest)</span>
10557 <div class="export-group">
10558 <button type="button" class="export-btn" id="mc-file-reset-btn">↻ Reset</button>
10559 <button type="button" class="export-btn" id="export-csv-btn">
10560 <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>
10561 CSV
10562 </button>
10563 <button type="button" class="export-btn" id="mc-file-xls-btn">
10564 <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>
10565 Excel
10566 </button>
10567 </div>
10568 </div>
10569 </div>
10570 <div class="table-wrap">
10571 <table id="file-table">
10572 <thead>
10573 <tr>
10574 <th class="left sortable" data-sort-col="p" data-sort-type="str">File <span class="sort-icon">↕</span></th>
10575 <th class="left sortable" data-sort-col="l" data-sort-type="str">Language <span class="sort-icon">↕</span></th>
10576 <th class="left sortable" data-sort-col="s" data-sort-type="str">Status <span class="sort-icon">↕</span></th>
10577 {file_col_headers}
10578 <th class="file-net-col sortable" data-sort-col="t" data-sort-type="num">Net Δ <span class="sort-icon">↕</span></th>
10579 </tr>
10580 </thead>
10581 <tbody id="file-tbody"></tbody>
10582 </table>
10583 </div>
10584 <div class="pagination">
10585 <span class="pagination-info" id="pg-info"></span>
10586 <div class="pagination-btns" id="pg-btns"></div>
10587 <div style="display:flex;align-items:center;gap:6px;">
10588 <span style="font-size:12px;color:var(--muted)">Show</span>
10589 <select class="per-page" id="per-page-sel">
10590 <option value="25" selected>25 per page</option>
10591 <option value="50">50 per page</option>
10592 <option value="100">100 per page</option>
10593 </select>
10594 </div>
10595 </div>
10596 </div>
10597 </div>
10598
10599 <div id="mc-ic-tt"></div>
10600
10601 <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
10602 <div class="ic-svg-modal">
10603 <div class="ic-svg-modal-hdr">
10604 <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
10605 <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">× Close</button>
10606 </div>
10607 <div id="ic-svg-modal-body"></div>
10608 </div>
10609 </div>
10610
10611 <footer class="site-footer">
10612 oxide-sloc v{version} — local code metrics workbench ·
10613 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
10614 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
10615 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
10616 · <a href="/api-docs" rel="noopener">REST API</a>
10617 </footer>
10618
10619 <script nonce="{csp_nonce}">
10620 (function(){{
10621 // ── Dark theme ───────────────────────────────────────────────────────────
10622 try{{if(localStorage.getItem('sloc-dark')==='1')document.body.classList.add('dark-theme');}}catch(e){{}}
10623 var renderInlineCharts=null;
10624 var tt=document.getElementById('theme-toggle');
10625 if(tt)tt.addEventListener('click',function(){{
10626 var on=document.body.classList.toggle('dark-theme');
10627 try{{localStorage.setItem('sloc-dark',on?'1':'0');}}catch(e){{}}
10628 renderChart(activeMetric);
10629 if(renderInlineCharts)renderInlineCharts();
10630 }});
10631
10632 // ── Code particles ───────────────────────────────────────────────────────
10633 var container=document.getElementById('code-particles');
10634 if(container){{
10635 var snips=['multi-scan','timeline','code_lines','fn delta()','+230 loc','-15 files','v1.0','git main','scan 3','commits','trend','coverage','tests: 145','sloc_core','analyze()'];
10636 for(var i=0;i<28;i++){{
10637 (function(idx){{
10638 var el=document.createElement('span');el.className='code-particle';
10639 el.textContent=snips[idx%snips.length];
10640 el.style.left=(Math.random()*94+2).toFixed(1)+'%';
10641 el.style.top=(Math.random()*88+6).toFixed(1)+'%';
10642 el.style.setProperty('--rot',(Math.random()*26-13).toFixed(1)+'deg');
10643 el.style.setProperty('--op',(Math.random()*0.08+0.05).toFixed(3));
10644 el.style.animationDuration=(Math.random()*10+9).toFixed(1)+'s';
10645 el.style.animationDelay='-'+(Math.random()*18).toFixed(1)+'s';
10646 container.appendChild(el);
10647 }})(i);
10648 }}
10649 }}
10650
10651 // ── Watermarks ───────────────────────────────────────────────────────────
10652 var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
10653 if(wms.length){{
10654 var placed=[];
10655 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;}}
10656 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];}}
10657 var half=Math.floor(wms.length/2);
10658 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;}});
10659 }}
10660
10661 // ── Settings / colour scheme modal ───────────────────────────────────────
10662 (function(){{
10663 var S=[{{n:'Classic',a:'#b85d33',b:'#7a371b'}},{{n:'Navy',a:'#283790',b:'#1e1e24'}},{{n:'Ember',a:'#ce5d3d',b:'#1e1e24'}},{{n:'Ocean',a:'#1f439b',b:'#1e1e24'}},{{n:'Royal',a:'#003184',b:'#1e1e24'}}];
10664 function ap(s){{document.documentElement.style.setProperty('--nav',s.a);document.documentElement.style.setProperty('--nav-2',s.b);try{{localStorage.setItem('sloc-ns',JSON.stringify(s));}}catch(e){{}}document.querySelectorAll('.scheme-swatch').forEach(function(x){{x.classList.toggle('active',x.dataset.n===s.n);}});}}
10665 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a)ap(sv);else ap(S[0]);}}catch(e){{ap(S[0]);}}
10666 function init(){{
10667 var btn=document.getElementById('settings-btn');if(!btn)return;
10668 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
10669 m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close-btn" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
10670 document.body.appendChild(m);
10671 var g=document.getElementById('scheme-grid');
10672 if(g)S.forEach(function(s){{var el=document.createElement('button');el.type='button';el.className='scheme-swatch';el.dataset.n=s.n;el.title=s.n;var p=document.createElement('div');p.className='scheme-preview';p.style.background='linear-gradient(135deg,'+s.a+','+s.b+')';var l=document.createElement('span');l.className='scheme-label';l.textContent=s.n;el.appendChild(p);el.appendChild(l);try{{var c=JSON.parse(localStorage.getItem('sloc-ns'));if(c&&c.n===s.n)el.classList.add('active');}}catch(e){{}}el.addEventListener('click',function(){{ap(s);}});g.appendChild(el);}});
10673 var cl=document.getElementById('settings-close-btn');
10674 btn.addEventListener('click',function(e){{e.stopPropagation();var r=btn.getBoundingClientRect();m.style.top=(r.bottom+6)+'px';m.style.right=(window.innerWidth-r.right)+'px';m.classList.toggle('open');}});
10675 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
10676 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
10677 }}
10678 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
10679 }})();
10680
10681 // ── Timezone support for scan timestamps ─────────────────────────────────
10682 (function(){{
10683 window.tzAbbr=function(z){{return{{'America/Los_Angeles':'PT','America/Denver':'MT','America/Chicago':'CT','America/New_York':'ET','America/Anchorage':'AT','Pacific/Honolulu':'HT'}}[z]||'PT';}};window.tzCity=function(z){{return{{'America/Los_Angeles':'Los Angeles','America/Denver':'Denver','America/Chicago':'Chicago','America/New_York':'New York','America/Anchorage':'Anchorage','Pacific/Honolulu':'Honolulu'}}[z]||'';}};window.tzOffset=function(z){{var r='';try{{var p=new Intl.DateTimeFormat('en-US',{{timeZone:z,timeZoneName:'longOffset'}}).formatToParts(new Date());p.forEach(function(x){{if(x.type==='timeZoneName')r=x.value.replace('GMT','UTC');}});}}catch(e){{}}return r;}};window.tf24=function(){{try{{return localStorage.getItem('sloc-tf')!=='12';}}catch(e){{return true;}}}};window.enhanceTzOptions=function(sel){{if(!sel)return;Array.prototype.forEach.call(sel.options,function(o){{var base=o.textContent.split(' - ')[0];var city=window.tzCity(o.value);var off=window.tzOffset(o.value);o.textContent=base+(city?' - '+city:'')+(off?' - '+off:'');}});}};window.applyTf=function(tf){{try{{localStorage.setItem('sloc-tf',tf);}}catch(e){{}}var z;try{{z=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{z='America/Los_Angeles';}}window.applyTz(z);}};
10684 window.fmtTz=function(ms,tz){{var d=new Date(ms);if(isNaN(d.getTime()))return'';var h24=window.tf24();try{{var pts=new Intl.DateTimeFormat('en-US',{{timeZone:tz,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:!h24}}).formatToParts(d);var v={{}};pts.forEach(function(p){{v[p.type]=p.value;}});var t=v.hour+':'+v.minute;if(!h24&&v.dayPeriod)t+=' '+v.dayPeriod;return v.year+'-'+v.month+'-'+v.day+' '+t+' '+window.tzAbbr(tz);}}catch(e){{return'';}}}};
10685 window.applyTz=function(tz){{try{{localStorage.setItem('sloc-tz',tz);}}catch(e){{}}document.querySelectorAll('[data-utc-ms]').forEach(function(el){{var ms=parseInt(el.getAttribute('data-utc-ms'),10);if(!isNaN(ms))el.textContent=window.fmtTz(ms,tz);}});}};
10686 var storedTz;try{{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{storedTz='America/Los_Angeles';}}
10687 window.applyTz(storedTz);
10688 function wireTzSelect(){{var tzSel=document.getElementById('tz-select');if(!tzSel)return;window.enhanceTzOptions(tzSel);tzSel.value=storedTz;tzSel.addEventListener('change',function(){{window.applyTz(this.value);}});if(!document.getElementById('tf-select')&&tzSel.parentNode){{var tw=document.createElement('div');tw.style.marginTop='10px';var tl=document.createElement('div');tl.className='settings-modal-label';tl.style.marginBottom='8px';tl.textContent='Time format';var tfSel=document.createElement('select');tfSel.className='tz-select';tfSel.id='tf-select';tfSel.innerHTML='<option value="24">24-hour (14:30)</option><option value="12">12-hour (2:30 PM)</option>';tw.appendChild(tl);tw.appendChild(tfSel);tzSel.parentNode.appendChild(tw);var storedTf;try{{storedTf=localStorage.getItem('sloc-tf')||'24';}}catch(e){{storedTf='24';}}tfSel.value=storedTf;tfSel.addEventListener('change',function(){{window.applyTf(this.value);}});}}}}
10689 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',wireTzSelect);else setTimeout(wireTzSelect,50);
10690 }})();
10691
10692 // ── Data ────────────────────────────────────────────────────────────────
10693 var POINTS={points_json};
10694 var FILES={file_matrix_json};
10695 var N={n};
10696
10697 // ── fmt helper ───────────────────────────────────────────────────────────
10698 function fmt(n){{var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}}
10699 function fmtFull(n){{return Number(n).toLocaleString();}}
10700 function fmtDelta(n){{return n>0?'+'+fmtFull(n):fmtFull(n);}}
10701
10702 // ── Export filename: <project>_<n_scans>_<first_scan_short_commit> ──
10703 function mcExportProj(){{return ('{project_label}'.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,''))||'project';}}
10704 function mcShortRef(p,i){{var c=(p&&p.commit?String(p.commit):'').replace(/[^A-Za-z0-9]/g,'').slice(0,7);if(c)return c;var r=(p&&p.run_id?String(p.run_id):'').replace(/[^A-Za-z0-9]/g,'').slice(0,7);return r||('scan'+(i+1));}}
10705 function mcExportBase(){{var first=POINTS.length?mcShortRef(POINTS[0],0):'scan1';return mcExportProj()+'_'+POINTS.length+'_'+first;}}
10706 function mcExportName(ext){{return mcExportBase()+'.'+ext;}}
10707
10708 // ── Timeline chart ───────────────────────────────────────────────────────
10709 var activeMetric='code';
10710 var metricKey={{code:'code',files:'files',comments:'comments',tests:'tests',cov:'cov'}};
10711 var metricLabel={{code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'}};
10712
10713 function renderChart(metric){{
10714 var svg=document.getElementById('mc-chart');if(!svg)return;
10715 var W=svg.getBoundingClientRect().width||800,H=280;
10716 svg.setAttribute('height',H);
10717 var pad={{l:62,r:20,t:32,b:72}};
10718 var dark=document.body.classList.contains('dark-theme');
10719 var pts=POINTS.map(function(p){{return p[metric]!=null?Number(p[metric]):null;}});
10720 var valid=pts.filter(function(v){{return v!=null;}});
10721 if(!valid.length){{var _nd_dark=document.body.classList.contains('dark-theme');var _nd_bg=_nd_dark?'#241a12':'#fbf7f2';var _nd_tc=_nd_dark?'rgba(255,255,255,0.30)':'rgba(67,52,45,0.32)';var _nd_ts=_nd_dark?'rgba(255,255,255,0.55)':'rgba(67,52,45,0.60)';var _nd_lbl=(metricLabel[metric]||metric);var _nd_cov=metric==='cov';var _nd_msg=_nd_cov?'No coverage data for these scans':'No '+_nd_lbl.toLowerCase()+' recorded';var _nd_sub=_nd_cov?'Coverage appears once test results are captured during a scan.':'None of the selected scans reported a value for this metric.';var _cx=W/2,_cy=H/2;svg.setAttribute('viewBox','0 0 '+W+' '+H);svg.innerHTML='<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+_nd_bg+'" rx="8"/>'+'<g opacity="0.55"><rect x="'+(_cx-28).toFixed(1)+'" y="'+(_cy-50).toFixed(1)+'" width="56" height="34" rx="5" fill="none" stroke="'+_nd_tc+'" stroke-width="1.6"/><polyline points="'+(_cx-20).toFixed(1)+','+(_cy-24).toFixed(1)+' '+(_cx-7).toFixed(1)+','+(_cy-30).toFixed(1)+' '+(_cx+6).toFixed(1)+','+(_cy-26).toFixed(1)+' '+(_cx+20).toFixed(1)+','+(_cy-34).toFixed(1)+'" fill="none" stroke="'+_nd_tc+'" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></g>'+'<text x="'+_cx.toFixed(1)+'" y="'+(_cy+4).toFixed(1)+'" text-anchor="middle" font-size="14" font-weight="700" fill="'+_nd_ts+'">'+escHtml(_nd_msg)+'</text>'+'<text x="'+_cx.toFixed(1)+'" y="'+(_cy+24).toFixed(1)+'" text-anchor="middle" font-size="11.5" fill="'+_nd_tc+'">'+escHtml(_nd_sub)+'</text>';return;}}
10722 var minV=0,maxV=Math.max.apply(null,valid);
10723 if(maxV<=0){{maxV=1;}}else{{maxV=maxV*1.08;}}
10724 var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
10725 function xOf(i){{return pad.l+(N===1?plotW/2:i/(N-1)*plotW);}}
10726 function yOf(v){{return pad.t+plotH-(v-minV)/(maxV-minV)*plotH;}}
10727 var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
10728 var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
10729 var lineColor='#d37a4c';var dotColor='#d37a4c';var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
10730 var parts=[];
10731 parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
10732 for(var gi=0;gi<5;gi++){{var gy=pad.t+plotH/4*gi;parts.push('<line x1="'+pad.l+'" y1="'+gy.toFixed(1)+'" x2="'+(W-pad.r)+'" y2="'+gy.toFixed(1)+'" stroke="'+gridColor+'" stroke-width="1"/>');var gv=maxV-(maxV-minV)/4*gi;parts.push('<text x="'+(pad.l-6)+'" y="'+(gy+4).toFixed(1)+'" text-anchor="end" font-size="10" fill="'+textColor+'">'+fmt(gv)+'</text>');}}
10733 var areaD='M '+xOf(0)+' '+(pad.t+plotH);
10734 var lineD='';var firstPt=true;
10735 for(var i=0;i<N;i++){{if(pts[i]==null)continue;var cx=xOf(i),cy=yOf(pts[i]);areaD+=' L '+cx.toFixed(1)+' '+cy.toFixed(1);if(firstPt){{lineD='M '+cx.toFixed(1)+' '+cy.toFixed(1);firstPt=false;}}else{{lineD+=' L '+cx.toFixed(1)+' '+cy.toFixed(1);}}}}
10736 areaD+=' L '+xOf(N-1)+' '+(pad.t+plotH)+' Z';
10737 parts.push('<path d="'+areaD+'" fill="'+areaColor+'"/>');
10738 parts.push('<path d="'+lineD+'" fill="none" stroke="'+lineColor+'" stroke-width="2.2" stroke-linejoin="round"/>');
10739 for(var i=0;i<N;i++){{
10740 if(pts[i]==null)continue;
10741 var cx=xOf(i),cy=yOf(pts[i]);
10742 var p=POINTS[i];var lbl=(p.commit||'').substring(0,7)||(i+1)+'';
10743 var hasTag=p.tags&&p.tags.length>0;
10744 // Permanent Y-value label above the dot
10745 parts.push('<text x="'+cx.toFixed(1)+'" y="'+(cy-11).toFixed(1)+'" text-anchor="middle" font-size="11" font-weight="600" fill="'+textColor+'">'+fmtFull(pts[i])+'</text>');
10746 parts.push('<circle cx="'+cx.toFixed(1)+'" cy="'+cy.toFixed(1)+'" r="'+(hasTag?5.5:4)+'" fill="'+(hasTag?'#6f9bff':dotColor)+'" stroke="'+(dark?'#241a12':'#fbf7f2')+'" stroke-width="1.5" style="cursor:pointer" data-run-id="'+p.run_id+'"/>');
10747 var xanchor=i===0?'start':i===N-1?'end':'middle';
10748 // X-axis label at 2× the original size (18 px)
10749 parts.push('<text x="'+cx.toFixed(1)+'" y="'+(H-pad.b+22)+'" text-anchor="'+xanchor+'" font-size="18" fill="'+textColor+'" font-family="ui-monospace,monospace">'+escHtml(lbl)+'</text>');
10750 }}
10751 parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escHtml(metricLabel[metric]||metric)+'</text>');
10752 svg.setAttribute('viewBox','0 0 '+W+' '+H);
10753 svg.innerHTML=parts.join('');
10754 svg.addEventListener('click',function(e){{var c=e.target.closest('circle[data-run-id]');if(c)window.location='/runs/html/'+c.getAttribute('data-run-id');}});
10755 // ── Interactive hover: vertical crosshair + tooltip ───────────────────
10756 svg.onmousemove=function(e){{
10757 var rect=svg.getBoundingClientRect();
10758 var scaleX=W/rect.width;
10759 var mouseX=(e.clientX-rect.left)*scaleX;
10760 var nearest=-1,minDist=Infinity;
10761 for(var k=0;k<N;k++){{if(pts[k]==null)continue;var dx=Math.abs(xOf(k)-mouseX);if(dx<minDist){{minDist=dx;nearest=k;}}}}
10762 if(nearest<0)return;
10763 var nc=xOf(nearest),ny=yOf(pts[nearest]);
10764 var xhair=svg.querySelector('.mc-xhair');
10765 if(!xhair){{xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','mc-xhair');svg.appendChild(xhair);}}
10766 xhair.innerHTML='<line x1="'+nc.toFixed(1)+'" y1="'+pad.t+'" x2="'+nc.toFixed(1)+'" y2="'+(pad.t+plotH)+'" stroke="rgba(211,122,76,0.55)" stroke-width="1.5" stroke-dasharray="4,3" pointer-events="none"/>';
10767 var tt=document.getElementById('mc-ic-tt');if(!tt)return;
10768 var pp=POINTS[nearest];var clbl=(pp.commit||'').substring(0,7)||(nearest+1)+'';
10769 tt.innerHTML='<strong>Scan '+(nearest+1)+'</strong> <span style="font-family:monospace;font-size:11px;opacity:.75">'+escHtml(clbl)+'</span><br>'+escHtml(metricLabel[metric]||metric)+': <strong>'+fmtFull(pts[nearest])+'</strong>';
10770 var bx=rect.left+(nc/W*rect.width)+18;
10771 if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
10772 tt.style.left=bx+'px';tt.style.top=(e.clientY-38)+'px';tt.style.display='block';
10773 }};
10774 svg.onmouseleave=function(){{
10775 var xhair=svg.querySelector('.mc-xhair');if(xhair)xhair.innerHTML='';
10776 var tt=document.getElementById('mc-ic-tt');if(tt)tt.style.display='none';
10777 }};
10778 }}
10779
10780 function escHtml(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
10781
10782 document.querySelectorAll('.chart-metric-btn').forEach(function(btn){{
10783 btn.addEventListener('click',function(){{
10784 activeMetric=this.dataset.metric;
10785 document.querySelectorAll('.chart-metric-btn').forEach(function(b){{b.classList.remove('active');}});
10786 this.classList.add('active');
10787 renderChart(activeMetric);
10788 }});
10789 }});
10790 if(typeof ResizeObserver!=='undefined'){{
10791 new ResizeObserver(function(){{renderChart(activeMetric);}}).observe(document.getElementById('mc-chart'));
10792 }}
10793 renderChart(activeMetric);
10794
10795 // ── File matrix table ────────────────────────────────────────────────────
10796 var activeStatus='';
10797 var currentPage=1;
10798 var perPage=25;
10799 var mcSortCol=null,mcSortAsc=true;
10800
10801 function getFiltered(){{
10802 var data=!activeStatus?FILES:FILES.filter(function(f){{return f.s===activeStatus;}});
10803 if(!mcSortCol)return data;
10804 var asc=mcSortAsc;
10805 return data.slice().sort(function(a,b){{
10806 var va,vb;
10807 if(mcSortCol==='p'){{va=a.p||'';vb=b.p||'';}}
10808 else if(mcSortCol==='l'){{va=a.l||'';vb=b.l||'';}}
10809 else if(mcSortCol==='s'){{va=a.s||'';vb=b.s||'';}}
10810 else if(mcSortCol==='t'){{va=a.t||0;vb=b.t||0;return asc?va-vb:vb-va;}}
10811 else{{return 0;}}
10812 if(asc)return va<vb?-1:va>vb?1:0;
10813 return va<vb?1:va>vb?-1:0;
10814 }});
10815 }}
10816
10817 function renderFilePage(){{
10818 var filtered=getFiltered();
10819 var total=filtered.length;
10820 var totalPages=Math.max(1,Math.ceil(total/perPage));
10821 if(currentPage>totalPages)currentPage=totalPages;
10822 var start=(currentPage-1)*perPage,end=Math.min(start+perPage,total);
10823 var tbody=document.getElementById('file-tbody');if(!tbody)return;
10824 var rows=[];
10825 for(var i=start;i<end;i++){{
10826 var f=filtered[i];
10827 var cells='<td class="left"><span class="file-path" title="'+escHtml(f.p)+'">'+escHtml(f.p)+'</span></td>';
10828 cells+='<td class="left">'+(f.l?escHtml(f.l):'<span class="absent">\u2014</span>')+'</td>';
10829 cells+='<td class="left"><span class="status-badge '+f.s+'">'+f.s+'</span></td>';
10830 for(var j=0;j<N;j++){{
10831 var cv=f.c[j];
10832 cells+='<td class="file-scan-col">'+(cv!=null?fmtFull(cv):'<span class="absent">\u2014</span>')+'</td>';
10833 if(j<N-1){{
10834 var dv=f.d[j+1];
10835 cells+='<td class="file-delta-col '+(dv!=null?dv>0?'pos':dv<0?'neg':'zero':'absent-delta')+'">'+
10836 (dv!=null?fmtDelta(dv):'<span class="absent">\u2014</span>')+'</td>';
10837 }}
10838 }}
10839 var tc=f.t;
10840 cells+='<td class="file-net-col '+(tc>0?'pos':tc<0?'neg':'zero')+'">'+fmtDelta(tc)+'</td>';
10841 rows.push('<tr class="row-'+f.s+'">'+cells+'</tr>');
10842 }}
10843 tbody.innerHTML=rows.join('');
10844
10845 var info=document.getElementById('pg-info');
10846 if(info)info.textContent='Showing '+(total?start+1:0)+'\u2013'+end+' of '+total+' files';
10847 renderPgBtns(totalPages);
10848 }}
10849
10850 function renderPgBtns(totalPages){{
10851 var wrap=document.getElementById('pg-btns');if(!wrap)return;
10852 var btns=[];
10853 function mkBtn(label,page,active,disabled){{
10854 var cls='pg-btn'+(active?' active':'')+(disabled?' disabled':'');
10855 return '<button class="'+cls+'" data-pg="'+page+'" '+(disabled?'disabled':'')+'>'+label+'</button>';
10856 }}
10857 btns.push(mkBtn('‹',currentPage-1,false,currentPage<=1));
10858 var s=Math.max(1,currentPage-2),e=Math.min(totalPages,currentPage+2);
10859 if(s>1)btns.push(mkBtn('1',1,false,false));
10860 if(s>2)btns.push('<span class="pg-btn" style="pointer-events:none">…</span>');
10861 for(var p=s;p<=e;p++)btns.push(mkBtn(p,p,p===currentPage,false));
10862 if(e<totalPages-1)btns.push('<span class="pg-btn" style="pointer-events:none">…</span>');
10863 if(e<totalPages)btns.push(mkBtn(totalPages,totalPages,false,false));
10864 btns.push(mkBtn('›',currentPage+1,false,currentPage>=totalPages));
10865 wrap.innerHTML=btns.join('');
10866 wrap.querySelectorAll('.pg-btn[data-pg]').forEach(function(b){{
10867 b.addEventListener('click',function(){{
10868 var pg=parseInt(this.dataset.pg,10);
10869 if(pg>=1&&pg<=totalPages){{currentPage=pg;renderFilePage();}}
10870 }});
10871 }});
10872 }}
10873
10874 // Tab filter
10875 document.querySelectorAll('.tab-btn').forEach(function(btn){{
10876 btn.addEventListener('click',function(){{
10877 activeStatus=this.dataset.status||'';
10878 currentPage=1;
10879 document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
10880 this.classList.add('active');
10881 renderFilePage();
10882 }});
10883 }});
10884
10885 // Per-page selector
10886 var ppSel=document.getElementById('per-page-sel');
10887 if(ppSel)ppSel.addEventListener('change',function(){{perPage=parseInt(this.value,10)||25;currentPage=1;renderFilePage();}});
10888
10889 // ── Column header sort ───────────────────────────────────────────────────
10890 Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(th){{
10891 th.addEventListener('click',function(){{
10892 var col=th.dataset.sortCol;
10893 if(mcSortCol===col){{mcSortAsc=!mcSortAsc;}}else{{mcSortCol=col;mcSortAsc=true;}}
10894 Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
10895 var si=t.querySelector('.sort-icon');if(si)si.innerHTML='↕';t.classList.remove('sort-asc','sort-desc');
10896 }});
10897 th.classList.add(mcSortAsc?'sort-asc':'sort-desc');
10898 var si=th.querySelector('.sort-icon');if(si)si.innerHTML=mcSortAsc?'↑':'↓';
10899 currentPage=1;renderFilePage();
10900 }});
10901 }});
10902
10903 // Reset button also clears sort
10904 var mcResetBtn=document.getElementById('mc-file-reset-btn');
10905 if(mcResetBtn)mcResetBtn.addEventListener('click',function(){{
10906 mcSortCol=null;mcSortAsc=true;
10907 Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
10908 var si=t.querySelector('.sort-icon');if(si)si.innerHTML='↕';t.classList.remove('sort-asc','sort-desc');
10909 }});
10910 activeStatus='';currentPage=1;
10911 document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
10912 var allBtn=document.querySelector('.tab-btn');if(allBtn)allBtn.classList.add('active');
10913 renderFilePage();
10914 }});
10915
10916 renderFilePage();
10917
10918 // ── CSV export ───────────────────────────────────────────────────────────
10919 var exportBtn=document.getElementById('export-csv-btn');
10920 if(exportBtn)exportBtn.addEventListener('click',function(){{
10921 var header=['File','Language','Status'];
10922 for(var i=0;i<N;i++){{header.push('Scan '+(i+1)+' Code');if(i<N-1)header.push('Delta->'+(i+2));}}
10923 header.push('Net Delta');
10924 var rows=[header.map(function(h){{return '"'+h.replace(/"/g,'""')+'"';}}).join(',')];
10925 var filtered=getFiltered();
10926 filtered.forEach(function(f){{
10927 var cols=['"'+f.p.replace(/"/g,'""')+'"','"'+(f.l||'')+'"','"'+f.s+'"'];
10928 for(var j=0;j<N;j++){{
10929 cols.push(f.c[j]!=null?f.c[j]:'');
10930 if(j<N-1)cols.push(f.d[j+1]!=null?f.d[j+1]:'');
10931 }}
10932 cols.push(f.t);
10933 rows.push(cols.join(','));
10934 }});
10935 var blob=new Blob([rows.join('\r\n')],{{type:'text/csv'}});
10936 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
10937 a.download=mcExportName('csv');a.click();
10938 }});
10939
10940 // ── File matrix extra export buttons ─────────────────────────────────────
10941 (function(){{
10942 var resetBtn=document.getElementById('mc-file-reset-btn');
10943 if(resetBtn)resetBtn.addEventListener('click',function(){{
10944 activeStatus='';currentPage=1;
10945 document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
10946 var allBtn=document.querySelector('.tab-btn.tab-all');if(allBtn)allBtn.classList.add('active');
10947 renderFilePage();
10948 }});
10949
10950 // \u2500\u2500 File Matrix Excel export \u2014 Summary + File Delta tabs (matches Scan Delta) \u2500\u2500
10951 function mcSignDelta(v){{if(v==null||v==='')return'';var n=+v;return n>0?'+'+n:String(n);}}
10952 function mcMakeXlsx(fname){{
10953 var filtered=getFiltered();
10954 var enc=new TextEncoder();
10955 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;}}
10956 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;}}
10957 function u2(n){{return[n&0xFF,(n>>8)&0xFF];}}
10958 function u4(n){{return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}}
10959 var ss=[],si={{}};
10960 function S(v){{v=String(v==null?'':v);if(!(v in si)){{si[v]=ss.length;ss.push(v);}}return si[v];}}
10961 function xe(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
10962 function WS(){{
10963 var R=0,buf=[];
10964 function cl(c){{return String.fromCharCode(65+c);}}
10965 function sc(c,v,st){{return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'><v>'+S(v)+'</v></c>';}}
10966 function nc(c,v,st){{return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+(st?' s="'+st+'"':'')+'><v>'+(+v)+'</v></c>';}}
10967 function row(cells){{if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}}
10968 function xml(cw){{return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetViews><sheetView workbookViewId="0"/></sheetViews><sheetFormatPr defaultRowHeight="15"/>'+(cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}}
10969 return{{sc:sc,nc:nc,row:row,xml:xml}};
10970 }}
10971 function dstyle(v){{var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}}
10972 var proj=mcExportProj();
10973 // \u2500\u2500 Summary sheet \u2500\u2500
10974 var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
10975 r1(s1(0,'OxideSLOC \u2014 Multi-Scan Timeline Report',1));
10976 r1(s1(0,proj,2));
10977 var firstTs=POINTS.length?(POINTS[0].scanned||''):'',lastTs=POINTS.length?(POINTS[POINTS.length-1].scanned||''):'';
10978 r1(s1(0,firstTs+' \u2192 '+lastTs+' ('+N+' scans)',2));
10979 r1('');
10980 r1(s1(0,'SCAN SUMMARY',8));
10981 r1(s1(0,'Scan',3)+s1(1,'Commit',3)+s1(2,'Branch',3)+s1(3,'Timestamp',3)+s1(4,'Code Lines',3)+s1(5,'Comment Lines',3)+s1(6,'Files',3)+s1(7,'Tests',3));
10982 POINTS.forEach(function(p,i){{
10983 var sha=(p.commit||'').replace(/[^A-Za-z0-9]/g,'').slice(0,7);
10984 r1(s1(0,'Scan '+(i+1))+s1(1,sha||'\u2014')+s1(2,p.branch||'\u2014')+s1(3,p.scanned||'')+n1(4,p.code,4)+n1(5,p.comments,4)+n1(6,p.files,4)+n1(7,p.tests,4));
10985 }});
10986 r1('');
10987 if(POINTS.length>1){{
10988 var pf=POINTS[0],pl=POINTS[POINTS.length-1];
10989 r1(s1(0,'NET CHANGE (Scan 1 \u2192 Scan '+N+')',8));
10990 r1(s1(0,'Metric',3)+s1(1,'Scan 1',3)+s1(2,'Scan '+N,3)+s1(3,'Delta',3));
10991 var nr=function(lbl,a,b){{var d=(+b)-(+a),ds=d>0?'+'+d:String(d);r1(s1(0,lbl)+n1(1,a,4)+n1(2,b,4)+s1(3,ds,dstyle(ds)));}};
10992 nr('Code Lines',pf.code,pl.code);
10993 nr('Comment Lines',pf.comments,pl.comments);
10994 nr('Files Analyzed',pf.files,pl.files);
10995 nr('Tests',pf.tests,pl.tests);
10996 r1('');
10997 }}
10998 var cMod=0,cAdd=0,cRem=0,cUnch=0;
10999 FILES.forEach(function(f){{var s=f.s;if(s==='modified')cMod++;else if(s==='added')cAdd++;else if(s==='removed')cRem++;else cUnch++;}});
11000 var totF=FILES.length||1;
11001 function pct(n){{return(n/totF*100).toFixed(1)+'%';}}
11002 r1(s1(0,'FILE CHANGES',8));
11003 r1(s1(0,'Category',3)+s1(1,'Count',3)+s1(2,'% of Total',3));
11004 r1(s1(0,'Modified')+n1(1,cMod,4)+s1(2,pct(cMod)));
11005 r1(s1(0,'Added')+n1(1,cAdd,4)+s1(2,pct(cAdd)));
11006 r1(s1(0,'Removed')+n1(1,cRem,4)+s1(2,pct(cRem)));
11007 r1(s1(0,'Unchanged')+n1(1,cUnch,4)+s1(2,pct(cUnch)));
11008 var lm={{}};
11009 FILES.forEach(function(f){{var l=f.l||'Unknown',d=+f.t||0;if(!lm[l])lm[l]={{f:0,d:0}};lm[l].f++;lm[l].d+=d;}});
11010 var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}});
11011 if(langs.length){{
11012 r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
11013 r1(s1(0,'Language',3)+s1(1,'Files',3)+s1(2,'Net Code Delta',3));
11014 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)));}});
11015 }}
11016 var sh1=W1.xml('<col min="1" max="1" width="22" customWidth="1"/><col min="2" max="8" width="15" customWidth="1"/>');
11017 // \u2500\u2500 File Delta sheet \u2500\u2500
11018 var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
11019 var hcells=s2(0,'File',3)+s2(1,'Language',3)+s2(2,'Status',3),hc=3;
11020 for(var hi=0;hi<N;hi++){{hcells+=s2(hc++,'Scan '+(hi+1)+' Code',3);if(hi<N-1)hcells+=s2(hc++,'Delta \u2192 '+(hi+2),3);}}
11021 hcells+=s2(hc,'Net Delta',3);
11022 r2(hcells);
11023 filtered.forEach(function(f){{
11024 var cells=s2(0,f.p)+s2(1,f.l||'')+s2(2,f.s||''),c=3;
11025 for(var j=0;j<N;j++){{cells+=n2(c++,f.c[j]!=null?f.c[j]:'',4);if(j<N-1){{var dv=mcSignDelta(f.d[j+1]);cells+=s2(c++,dv,dstyle(dv));}}}}
11026 var tv=mcSignDelta(f.t);cells+=s2(c,tv,dstyle(tv));
11027 r2(cells);
11028 }});
11029 var ncols=3+N+(N-1)+1;
11030 var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="'+ncols+'" width="13" customWidth="1"/>');
11031 var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+ss.map(function(v){{return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}}).join('')+'</sst>';
11032 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
11033 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>',
11034 '_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>',
11035 '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>',
11036 '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>',
11037 '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>',
11038 'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2}};
11039 var zparts=[],zcds=[],zoff=0,znf=0;
11040 ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'].forEach(function(name){{
11041 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
11042 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]);
11043 var entry=new Uint8Array(lha.length+nb.length+sz);entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);zparts.push(entry);
11044 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));
11045 var cde=new Uint8Array(cda.length+nb.length);cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);zcds.push(cde);
11046 zoff+=entry.length;znf++;
11047 }});
11048 var cdSz=zcds.reduce(function(s,b){{return s+b.length;}},0);
11049 var eocd=[0x50,0x4B,0x05,0x06,0,0,0,0].concat(u2(znf)).concat(u2(znf)).concat(u4(cdSz)).concat(u4(zoff)).concat([0,0]);
11050 var totalLen=zoff+cdSz+eocd.length,out=new Uint8Array(totalLen),pos=0;
11051 zparts.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
11052 zcds.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
11053 out.set(new Uint8Array(eocd),pos);
11054 var blob=new Blob([out],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}});
11055 var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
11056 }}
11057
11058 var xlsBtn=document.getElementById('mc-file-xls-btn');
11059 if(xlsBtn)xlsBtn.addEventListener('click',function(){{mcMakeXlsx(mcExportName('xlsx'));}});
11060
11061 // File matrix HTML export — interactive: sort by column, filter by status
11062 function mcFileBuildHtml(){{
11063 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
11064 var hdrs=['File','Language','Status'];
11065 for(var _i=0;_i<N;_i++){{hdrs.push('Scan '+(_i+1)+' Code');if(_i<N-1)hdrs.push('\u0394\u2192'+(_i+2));}}
11066 hdrs.push('Net \u0394');
11067 var SI=2;
11068 var allRows=FILES.map(function(f){{var r=[f.p,f.l||'',f.s||''];for(var _i=0;_i<N;_i++){{r.push(f.c[_i]!=null?f.c[_i]:null);if(_i<N-1)r.push(f.d[_i+1]!=null?f.d[_i+1]:null);}}r.push(f.t);return r;}});
11069 var dJson=JSON.stringify(allRows),hJson=JSON.stringify(hdrs);
11070 var cnt={{all:allRows.length}};
11071 allRows.forEach(function(r){{var s=r[SI];cnt[s]=(cnt[s]||0)+1;}});
11072 var now=new Date().toISOString().replace('T',' ').slice(0,16)+' UTC';
11073 var css='body{{margin:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#f5f2ee;color:#111;}}'+
11074 '.hd{{background:#1a2035;color:#fff;padding:14px 20px;display:flex;justify-content:space-between;align-items:flex-start;}}'+
11075 '.brand{{font-size:13px;font-weight:800;color:#c45c10;letter-spacing:.06em;}}'+
11076 '.ttl{{font-size:18px;font-weight:700;margin:2px 0 3px;}}'+
11077 '.sub{{font-size:12px;color:#99aabb;}}'+
11078 '.pg-meta{{font-size:11px;color:#8899aa;text-align:right;line-height:1.8;}}'+
11079 '.wr{{padding:16px 20px;}}'+
11080 '.fbar{{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;}}'+
11081 '.fb{{padding:4px 12px;border-radius:20px;border:1px solid #ccc;background:#fff;font-size:12px;font-weight:600;cursor:pointer;transition:all .12s;}}'+
11082 '.fb.on{{background:#c45c10;color:#fff;border-color:#c45c10;}}'+
11083 '.ibar{{font-size:12px;color:#888;margin-bottom:8px;}}'+
11084 '.tw{{overflow-x:auto;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,.09);}}'+
11085 'table{{width:100%;border-collapse:collapse;background:#fff;font-size:12px;}}'+
11086 'thead tr{{background:#1a2035;}}'+
11087 'th{{padding:6px 10px;color:#fff;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;text-align:left;white-space:nowrap;cursor:pointer;user-select:none;}}'+
11088 'th:hover{{background:#2a3050;}}'+
11089 'th span{{margin-left:4px;opacity:.55;font-size:10px;}}'+
11090 'td{{padding:5px 10px;border-bottom:1px solid #f0ece8;}}'+
11091 'tr:nth-child(even) td{{background:#faf7f4;}}'+
11092 'tr:hover td{{background:#f5f0ea;}}'+
11093 '.ap{{color:#2a6846;font-weight:700;}}.an{{color:#b23030;font-weight:700;}}'+
11094 '.ftr{{background:#1a2035;color:#7a8b9c;font-size:10px;padding:7px 20px;display:flex;justify-content:space-between;margin-top:16px;}}';
11095 var thH=hdrs.map(function(h,i){{return'<th data-ci="'+i+'">'+esc(h)+'<span>\u21c5</span></th>';}}).join('');
11096 var fH='<button class="fb on" data-f="">All ('+allRows.length+')</button>'+
11097 (cnt.modified?'<button class="fb" data-f="modified">Modified ('+cnt.modified+')</button>':'')+
11098 (cnt.added?'<button class="fb" data-f="added">Added ('+cnt.added+')</button>':'')+
11099 (cnt.removed?'<button class="fb" data-f="removed">Removed ('+cnt.removed+')</button>':'')+
11100 (cnt.unchanged?'<button class="fb" data-f="unchanged">Unchanged ('+cnt.unchanged+')</button>':'');
11101 var inlineJs='var ALL='+dJson+',HDRS='+hJson+',SI='+SI+',sc=-1,sd=1,sf="";'+
11102 'function fc(v,ci){{if(v==null)return"—";var s=String(v);'+
11103 'if(ci===SI){{return s==="added"?"<span class=\\"ap\\">added<\\/span>":s==="removed"?"<span class=\\"an\\">removed<\\/span>":s||"—";}}'+
11104 'var n=Number(v);if(ci>SI&&!isNaN(n)&&n!==0){{return n>0?"<span class=\\"ap\\">+"+n.toLocaleString()+"<\\/span>":"<span class=\\"an\\">"+n.toLocaleString()+"<\\/span>";}}'+
11105 'if(ci>=3&&typeof v==="number")return Number(v).toLocaleString();'+
11106 'return s.length>80?"<abbr title=\\""+s.replace(/"/g,""")+"\\" style=\\"cursor:help\\">"+s.slice(0,78)+"\u2026<\\/abbr>":esc(s);}}'+
11107 'function esc(s){{return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");}}'+
11108 'function render(){{var data=sf?ALL.filter(function(r){{return r[SI]===sf;}}):ALL.slice();'+
11109 'if(sc>=0)data.sort(function(a,b){{var av=a[sc],bv=b[sc];var an=Number(av),bn=Number(bv);'+
11110 'return(!isNaN(an)&&!isNaN(bn)?an-bn:String(av||"").localeCompare(String(bv||"")))*sd;}});'+
11111 'document.getElementById("tb").innerHTML=data.map(function(r){{return"<tr>"+HDRS.map(function(h,ci){{return"<td>"+fc(r[ci],ci)+"<\\/td>";}}).join("")+"<\\/tr>";}}).join("")'+
11112 '||"<tr><td colspan=\\""+HDRS.length+"\\" style=\\"text-align:center;color:#aaa;padding:14px\\">No files match.<\\/td><\\/tr>";'+
11113 'document.getElementById("ic").textContent=data.length+" of "+ALL.length+" files";}}'+
11114 'document.querySelectorAll(".fb").forEach(function(b){{b.onclick=function(){{sf=this.dataset.f||"";'+
11115 'document.querySelectorAll(".fb").forEach(function(x){{x.classList.remove("on");}});this.classList.add("on");render();}};}} );'+
11116 'document.querySelectorAll("th[data-ci]").forEach(function(th){{th.onclick=function(){{var ci=+this.dataset.ci;'+
11117 'sd=(sc===ci)?-sd:1;sc=ci;'+
11118 'document.querySelectorAll("th[data-ci]").forEach(function(t){{t.querySelector("span").textContent="\u21c5";}});'+
11119 'this.querySelector("span").textContent=sd>0?"\u25b2":"\u25bc";render();}};}} );'+
11120 'render();';
11121 return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>Multi-Scan File Matrix<\/title><style>'+css+'<\/style><\/head><body>'+
11122 '<div class="hd"><div><div class="brand">oxide-sloc<\/div><div class="ttl">Multi-Scan File Matrix<\/div>'+
11123 '<div class="sub">{project_label} · {n} scans<\/div><\/div>'+
11124 '<div class="pg-meta">'+allRows.length+' files<br>Generated: '+now+'<\/div><\/div>'+
11125 '<div class="wr"><div class="fbar">'+fH+'<\/div><div class="ibar" id="ic"><\/div>'+
11126 '<div class="tw"><table><thead><tr>'+thH+'<\/tr><\/thead><tbody id="tb"><\/tbody><\/table><\/div><\/div>'+
11127 '<div class="ftr"><span>oxide-sloc v{version}<\/span><span>Multi-Scan File Matrix<\/span><span>{project_label}<\/span><\/div>'+
11128 '<script>'+inlineJs+'<\/script><\/body><\/html>';
11129 }}
11130
11131 var htmlBtn=document.getElementById('mc-file-html-btn');
11132 if(htmlBtn)htmlBtn.addEventListener('click',function(){{
11133 var h=mcFileBuildHtml();
11134 var blob=new Blob([h],{{type:'text/html;charset=utf-8;'}});
11135 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
11136 a.download=mcExportName('files.html');a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
11137 }});
11138
11139 var pdfBtn=document.getElementById('mc-file-pdf-btn');
11140 if(pdfBtn)pdfBtn.addEventListener('click',function(){{
11141 window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('files.pdf'),button:pdfBtn}});
11142 }});
11143 }})();
11144
11145 // ── Inline scan charts (matching Scan Delta layout) ──────────────────────
11146 (function(){{
11147 var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
11148 // Deeper shade of each metric hue for "before"/Scan-1 bars — bold, not washed.
11149 var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
11150 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
11151 function fmt2(n){{return Number(n).toLocaleString();}}
11152 function px(n){{return Math.round(n);}}
11153 var _tt=document.getElementById('mc-ic-tt');
11154 function btt(l,v){{return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}}
11155 function addTT(el){{
11156 if(!el)return;
11157 el.addEventListener('mouseover',function(e){{
11158 var t=e.target.closest('[data-ttl]');
11159 if(t&&_tt){{
11160 var ttl=t.getAttribute('data-ttl');
11161 _tt.innerHTML='<strong>'+ttl+'</strong><br>'+t.getAttribute('data-ttv');
11162 _tt.style.display='block';mvTT(e);
11163 el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11164 el.querySelectorAll('[data-ttl]').forEach(function(x){{if(x.getAttribute('data-ttl')===ttl)x.style.filter='brightness(1.2)';}});
11165 }} else {{
11166 if(_tt)_tt.style.display='none';
11167 el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11168 }}
11169 }});
11170 el.addEventListener('mouseleave',function(){{
11171 if(_tt)_tt.style.display='none';
11172 el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11173 }});
11174 el.addEventListener('mousemove',function(e){{mvTT(e);}});
11175 }}
11176 function mvTT(e){{if(!_tt)return;var x=e.clientX+16,y=e.clientY-10,r=_tt.getBoundingClientRect();if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;_tt.style.left=x+'px';_tt.style.top=y+'px';}}
11177 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
11178 function buildCharts(){{
11179 if(N<2)return;
11180 var cs=getComputedStyle(document.body);
11181 function cv(name,fb){{var v=cs.getPropertyValue(name);return(v&&v.trim())||fb;}}
11182 var textCol=cv('--text','#43342d');
11183 var mutedCol=cv('--muted','#7b675b');
11184 var gFill=cv('--muted-2','#a08777');
11185 var LGY=cv('--line','#e6d0bf');
11186 var axisCol=cv('--line-strong','#d8bfad');
11187 var surf2col=cv('--surface-2','#f4ede4');
11188 var surfCol=cv('--surface','#fff8f0');
11189 var p0=POINTS[0],pLast=POINTS[N-1];
11190 var dark=document.body.classList.contains('dark-theme');
11191 var FADE=dark?'#524238':'#e6d0bf';
11192 var barBorder=dark?'rgba(255,255,255,0.40)':'rgba(0,0,0,0.62)';
11193 function niceMax(v){{var x=v||1;var p=Math.pow(10,Math.floor(Math.log10(x)));var n=x/p;var s=n<=1?1:n<=2?2:n<=2.5?2.5:n<=5?5:10;return s*p;}}
11194 var c1mets=[
11195 {{l:'Code Lines',b:Number(p0.code),c:Number(pLast.code),bc:OXD,cc:OX}},
11196 {{l:'Files',b:Number(p0.files),c:Number(pLast.files),bc:GND,cc:GN}},
11197 {{l:'Comments',b:Number(p0.comments),c:Number(pLast.comments),bc:GDD,cc:GD}}
11198 ];
11199 var maxV1=niceMax(Math.max.apply(null,c1mets.map(function(m){{return Math.max(m.b,m.c);}}))||1);
11200 // Code Metrics chart — grows to fill the height its grid row settled to (the
11201 // Language Code Delta sibling usually drives that), so it never sits short at
11202 // the top of an over-tall cell. C1W is fixed; C1H scales with the cell.
11203 function drawC1(){{
11204 var C1W=620,C1H=200;
11205 var c1host=document.getElementById('mc-ic-c1');
11206 var c1card=c1host?c1host.closest('.ic-card'):null;
11207 if(c1host&&c1card&&c1host.clientWidth>0){{
11208 var avW=c1host.clientWidth;
11209 var availPx=(c1card.getBoundingClientRect().bottom-16)-c1host.getBoundingClientRect().top;
11210 var wantH=availPx*C1W/avW;
11211 if(wantH>C1H)C1H=wantH;
11212 }}
11213 var c1mt=40,c1mb=34,c1ml=58,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=54,c1gap=10;
11214 var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11215 for(var gi=1;gi<=4;gi++){{
11216 var gy=c1mt+c1ph*(1-gi/4),gv=maxV1*gi/4;
11217 c1+='<line x1="'+c1ml+'" y1="'+px(gy)+'" x2="'+(C1W-c1mr)+'" y2="'+px(gy)+'" stroke="'+LGY+'" stroke-width="0.5" stroke-dasharray="4,3"/>';
11218 c1+='<text x="'+(c1ml-6)+'" y="'+(px(gy)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt(gv)+'</text>';
11219 }}
11220 c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
11221 c1+='<text x="'+(c1ml-6)+'" y="'+px(c1mt+c1ph+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">0</text>';
11222 c1mets.forEach(function(m,i){{
11223 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
11224 var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
11225 c1+='<text x="'+cx+'" y="18" text-anchor="middle" font-family="'+FONT+'" font-size="13" font-weight="700" fill="'+textCol+'">'+esc(m.l)+'</text>';
11226 c1+='<rect'+btt(m.l,'Scan 1: '+fmt2(m.b))+' x="'+c1x0+'" y="'+px(c1mt+c1ph-bh0)+'" width="'+c1bw+'" height="'+px(bh0)+'" fill="'+m.bc+'" rx="5" style="cursor:pointer;"/>';
11227 c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph-bh0-5)+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" font-weight="700" fill="'+textCol+'">'+fmt2(m.b)+'</text>';
11228 c1+='<rect'+btt(m.l,'Latest (Scan '+N+'): '+fmt2(m.c))+' x="'+c1x1+'" y="'+px(c1mt+c1ph-bh1)+'" width="'+c1bw+'" height="'+px(bh1)+'" fill="'+m.cc+'" rx="5" style="cursor:pointer;"/>';
11229 c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph-bh1-5)+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" font-weight="700" fill="'+textCol+'">'+fmt2(m.c)+'</text>';
11230 c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph+18)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="'+textCol+'">Scan 1</text>';
11231 c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph+18)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="'+textCol+'">Latest</text>';
11232 }});
11233 c1+='</svg>';
11234 return c1;
11235 }}
11236 // Chart 2: Delta by Metric (net delta first scan to last)
11237 var mets=[
11238 {{l:'Code Lines',v:Number(pLast.code)-Number(p0.code),mc:'#C45C10'}},
11239 {{l:'Files Analyzed',v:Number(pLast.files)-Number(p0.files),mc:'#2A6846'}},
11240 {{l:'Comment Lines',v:Number(pLast.comments)-Number(p0.comments),mc:GD}}
11241 ];
11242 var maxD=Math.max.apply(null,mets.map(function(m){{return Math.abs(m.v);}}));maxD=maxD||1;
11243 var C2W=530,rH=56,C2H=mets.length*rH+28,c2LW=144,c2RP=18,cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
11244 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11245 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
11246 mets.forEach(function(m,i){{
11247 var y=16+i*rH,bw=(m.v===0?0:Math.max(Math.abs(m.v)/maxD*maxBW,2)),col=m.v>=0?GN:RD,vcol=(m.v===0?textCol:col),bx=m.v>=0?cx2:cx2-bw,sign=m.v>=0?'+':'',vStr=sign+fmt2(m.v);
11248 c2+='<text x="'+(c2LW-8)+'" y="'+(y+22)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" font-weight="600" fill="'+textCol+'">'+esc(m.l)+'</text>';
11249 c2+='<rect'+btt(m.l,'Net delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3" style="cursor:pointer;"/>';
11250 if(bw>=52){{c2+='<text x="'+px(bx+bw/2)+'" y="'+(y+26)+'" text-anchor="middle" font-family="'+FONT+'" font-size="12" font-weight="700" fill="white">'+esc(vStr)+'</text>';}}
11251 else{{var vx2=m.v>=0?px(bx+bw)+6:px(bx)-6,anc2=m.v>=0?'start':'end';c2+='<text x="'+vx2+'" y="'+(y+26)+'" text-anchor="'+anc2+'" font-family="'+FONT+'" font-size="12" font-weight="700" fill="'+vcol+'">'+esc(vStr)+'</text>';}}
11252 }});
11253 c2+='</svg>';
11254 // Chart 3: Language Code Delta (from FILES net total_code_delta per language)
11255 var lm={{}};
11256 FILES.forEach(function(f){{var l=f.l||'Unknown';if(!lm[l])lm[l]={{f:0,d:0}};lm[l].f++;lm[l].d+=f.t;}});
11257 var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}}).slice(0,12);
11258 function drawC3(){{
11259 if(!langs.length)return'';
11260 var maxLD=Math.max.apply(null,langs.map(function(l){{return Math.abs(lm[l].d);}}));maxLD=maxLD||1;
11261 var C3W=550,c3LW=124,c3FW=52,cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
11262 var c3host=document.getElementById('mc-ic-c3');
11263 var c3card=document.getElementById('mc-ic-lang-card');
11264 var C3H=langs.length*30+24;
11265 if(c3host&&c3card&&c3host.clientWidth>0){{
11266 var avW=c3host.clientWidth;
11267 var availPx=(c3card.getBoundingClientRect().bottom-16)-c3host.getBoundingClientRect().top;
11268 var wantH=availPx*C3W/avW;
11269 if(wantH>C3H)C3H=wantH;
11270 }}
11271 var topPad=12,botPad=12,band=(C3H-topPad-botPad)/langs.length,barH=Math.min(22,band*0.5);
11272 var c3='<svg viewBox="0 0 '+C3W+' '+px(C3H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11273 c3+='<line x1="'+cx3+'" y1="'+topPad+'" x2="'+cx3+'" y2="'+px(C3H-botPad)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
11274 langs.forEach(function(l,i){{
11275 var e=lm[l],yc=topPad+band*(i+0.5),bw=(e.d===0?0:Math.max(Math.abs(e.d)/maxLD*maxLBW,2)),col=e.d>=0?GN:RD,vcol=(e.d===0?textCol:col),bx=e.d>=0?cx3:cx3-bw,sign=e.d>=0?'+':'',vStr=sign+fmt2(e.d);
11276 c3+='<text x="'+(c3LW-7)+'" y="'+px(yc+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="'+textCol+'">'+esc(l)+'</text>';
11277 c3+='<rect'+btt(l,'Net delta: '+vStr+' • '+e.f+' file'+(e.f!==1?'s':''))+' x="'+px(bx)+'" y="'+px(yc-barH/2)+'" width="'+px(bw)+'" height="'+px(barH)+'" fill="'+col+'" rx="3"/>';
11278 if(bw>=48){{c3+='<text x="'+px(bx+bw/2)+'" y="'+px(yc+4)+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" font-weight="700" fill="white">'+esc(vStr)+'</text>';}}
11279 else{{var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';c3+='<text x="'+vx3+'" y="'+px(yc+4)+'" text-anchor="'+anc3+'" font-family="'+FONT+'" font-size="10" font-weight="700" fill="'+vcol+'">'+esc(vStr)+'</text>';}}
11280 c3+='<text x="'+(C3W-5)+'" y="'+px(yc+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="9" fill="'+mutedCol+'">'+e.f+' file'+(e.f!==1?'s':'')+'</text>';
11281 }});
11282 c3+='</svg>';
11283 return c3;
11284 }}
11285 // Chart 4: File Change Distribution (donut left, legend right, % on slices)
11286 var fm=0,fa=0,fr=0,fu=0;
11287 FILES.forEach(function(f){{if(f.s==='modified')fm++;else if(f.s==='added')fa++;else if(f.s==='removed')fr++;else fu++;}});
11288 var segs=[{{l:'Modified',v:fm,c:OX}},{{l:'Added',v:fa,c:GN}},{{l:'Removed',v:fr,c:RD}},{{l:'Unchanged',v:fu,c:FADE}}].filter(function(s){{return s.v>0;}});
11289 var tot4=segs.reduce(function(a,s){{return a+s.v;}},0)||1;
11290 var C4W=380,C4H=210,cx4=104,cy4=105,Ro=80,Ri=50;
11291 function pctFill(c){{return c===FADE?textCol:'#ffffff';}}
11292 var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" style="max-width:440px;display:block;margin:0 auto;" xmlns="http://www.w3.org/2000/svg">',ang4=-Math.PI/2;
11293 if(segs.length===1){{
11294 c4+='<circle'+btt(segs[0].l,fmt2(segs[0].v)+' files • 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+Ro+'" fill="'+segs[0].c+'" stroke="'+surfCol+'" stroke-width="2.5"/>';
11295 c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="'+surfCol+'"/>';
11296 c4+='<text x="'+cx4+'" y="'+px(cy4-(Ro+Ri)/2+4)+'" text-anchor="middle" font-family="'+FONT+'" font-size="12" font-weight="700" fill="'+pctFill(segs[0].c)+'">100%</text>';
11297 }} else {{
11298 segs.forEach(function(s){{
11299 var sw=Math.min(s.v/tot4*2*Math.PI,2*Math.PI-0.001),a2=ang4+sw;
11300 var x1=cx4+Ro*Math.cos(ang4),y1=cy4+Ro*Math.sin(ang4),x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
11301 var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2),xi2=cx4+Ri*Math.cos(ang4),yi2=cy4+Ri*Math.sin(ang4);
11302 c4+='<path'+btt(s.l,fmt2(s.v)+' files • '+px(s.v/tot4*100)+'%')+' 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="'+surfCol+'" stroke-width="2.5"/>';
11303 if(sw>0.32){{var midA=ang4+sw/2,rr=(Ro+Ri)/2,lx=cx4+rr*Math.cos(midA),ly=cy4+rr*Math.sin(midA);c4+='<text x="'+px(lx)+'" y="'+px(ly+4)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" font-weight="700" fill="'+pctFill(s.c)+'">'+px(s.v/tot4*100)+'%</text>';}}
11304 ang4+=sw;
11305 }});
11306 }}
11307 c4+='<text x="'+cx4+'" y="'+(cy4-2)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="bold" fill="'+textCol+'">'+fmt2(tot4)+'</text>';
11308 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">total files</text>';
11309 var legX=212,legRowH=26,legBlockH=segs.length*legRowH,legStartY=cy4-legBlockH/2+legRowH/2;
11310 segs.forEach(function(s,i){{
11311 var ly=legStartY+i*legRowH,pct=px(s.v/tot4*100);
11312 c4+='<rect'+btt(s.l,fmt2(s.v)+' files • '+pct+'%')+' x="'+legX+'" y="'+px(ly-10)+'" width="13" height="13" fill="'+s.c+'" rx="2" style="cursor:pointer;"/>';
11313 c4+='<text'+btt(s.l,fmt2(s.v)+' files • '+pct+'%')+' x="'+(legX+20)+'" y="'+px(ly+1)+'" font-family="'+FONT+'" font-size="12" font-weight="600" fill="'+textCol+'" style="cursor:pointer;">'+esc(s.l)+'</text>';
11314 c4+='<text x="'+(legX+20)+'" y="'+px(ly+15)+'" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt2(s.v)+' files • '+pct+'%</text>';
11315 }});
11316 c4+='</svg>';
11317 // Inject the fixed-size siblings first, then size Code Metrics (c1) and
11318 // Language Code Delta (c3) to fill the shared grid-row height. c1 is drawn
11319 // once at natural height to seed the row, then both are filled to the row the
11320 // grid settled to, so neither sits short at the top of an over-tall cell.
11321 var lc=document.getElementById('mc-ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
11322 var e2=document.getElementById('mc-ic-c2');if(e2)e2.innerHTML=c2;
11323 var e4=document.getElementById('mc-ic-c4');if(e4)e4.innerHTML=c4;
11324 var e1=document.getElementById('mc-ic-c1');if(e1)e1.innerHTML=drawC1();
11325 var e3=document.getElementById('mc-ic-c3');if(e3)e3.innerHTML=langs.length?drawC3():'<p style="color:var(--muted);font-size:13px;padding:8px 0 0;">No language delta.</p>';
11326 if(e1)e1.innerHTML=drawC1();
11327 }}
11328 buildCharts();
11329 renderInlineCharts=buildCharts;
11330 ['mc-ic-c1','mc-ic-c2','mc-ic-c3','mc-ic-c4'].forEach(function(id){{var el=document.getElementById(id);if(el)addTT(el);}});
11331 (function(){{
11332 var ov=document.getElementById('ic-svg-modal-ov');
11333 var body=document.getElementById('ic-svg-modal-body');
11334 var ttl=document.getElementById('ic-svg-modal-title');
11335 var closeBtn=document.getElementById('ic-svg-modal-close');
11336 if(!ov||!body)return;
11337 function close(){{ov.classList.remove('open');body.innerHTML='';}}
11338 function open(srcId,title){{
11339 var src=document.getElementById(srcId);if(!src)return;
11340 ttl.textContent=title||'';
11341 var card=src.closest('.ic-card');
11342 var legHtml='';
11343 if(card){{var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg" style="margin-bottom:14px;">'+leg.innerHTML+'</div>';}}
11344 body.innerHTML=legHtml+src.innerHTML;
11345 var svg=body.querySelector('svg');
11346 if(svg){{svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}}
11347 addTT(body);
11348 ov.classList.add('open');
11349 }}
11350 document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){{
11351 btn.addEventListener('click',function(){{open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));}});
11352 }});
11353 if(closeBtn)closeBtn.addEventListener('click',close);
11354 ov.addEventListener('click',function(e){{if(e.target===ov)close();}});
11355 document.addEventListener('keydown',function(e){{if(e.key==='Escape'&&ov.classList.contains('open'))close();}});
11356 }})();
11357
11358 // HTML legend hover → highlight matching SVG bars within the SAME card only
11359 document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){{
11360 var metric=leg.getAttribute('data-highlight');
11361 var parentCard=leg.closest('.ic-card');
11362 var chartEl=parentCard?parentCard.querySelector('[id]'):null;
11363 if(!chartEl)return;
11364 leg.addEventListener('mouseenter',function(){{
11365 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{
11366 if(x.getAttribute('data-ttl').indexOf(metric)===0){{
11367 x.style.filter='brightness(1.35) drop-shadow(0 2px 8px rgba(0,0,0,0.28))';
11368 x.style.opacity='1';
11369 }} else {{
11370 x.style.opacity='0.28';
11371 }}
11372 }});
11373 }});
11374 leg.addEventListener('mouseleave',function(){{
11375 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11376 }});
11377 }});
11378 // Author handles
11379 document.querySelectorAll('.cmp-author-val').forEach(function(el){{var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');}});
11380
11381 // ── Export helpers ────────────────────────────────────────────────────────
11382 // Fetch one image from the server and return a data-URI Promise
11383 function mcFetchUri(path){{
11384 return fetch(path).then(function(r){{return r.blob();}}).then(function(b){{
11385 return new Promise(function(res){{
11386 var rd=new FileReader();rd.onload=function(){{res(rd.result);}};rd.onerror=function(){{res('');}};rd.readAsDataURL(b);
11387 }});
11388 }}).catch(function(){{return '';}});
11389 }}
11390 // Replace /images/… src attrs in html with base64 data-URIs (async, callback)
11391 function mcInlineImgs(html,cb){{
11392 var paths=[],seen={{}};
11393 html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){{if(!seen[p]){{seen[p]=1;paths.push(p);}}return _;}});
11394 if(!paths.length){{cb(html);return;}}
11395 Promise.all(paths.map(function(p){{return mcFetchUri(p).then(function(u){{return{{p:p,u:u}};}}); }}))
11396 .then(function(rs){{rs.forEach(function(r){{if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');}});cb(html);}})
11397 .catch(function(){{cb(html);}});
11398 }}
11399 // Capture full-page HTML with all table rows visible
11400 function mcRawHtml(pdfMode){{
11401 if(pdfMode)document.body.classList.add('pdf-mode');
11402 var s=perPage,p=currentPage;perPage=FILES.length||999999;currentPage=1;renderFilePage();
11403 var html=document.documentElement.outerHTML;
11404 perPage=s;currentPage=p;renderFilePage();
11405 if(pdfMode)document.body.classList.remove('pdf-mode');
11406 return html;
11407 }}
11408
11409 // HTML export (full page with inlined images)
11410 function mcDoHtml(btn,fname){{
11411 var orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
11412 mcInlineImgs(mcRawHtml(false),function(html){{
11413 var blob=new Blob([html],{{type:'text/html;charset=utf-8;'}});
11414 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
11415 a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
11416 btn.disabled=false;btn.innerHTML=orig;
11417 }});
11418 }}
11419 // PDF export — comprehensive document-style report: full numbers, all sections
11420 function mcBuildPdfHtml(){{
11421 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
11422 function full(n){{if(n==null||n===''||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
11423 function dStr(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
11424 function dHtml(v){{var s=dStr(v);return Number(v)>0?'<span style="color:#2a6846;font-weight:700">'+s+'</span>':Number(v)<0?'<span style="color:#b23030;font-weight:700">'+s+'</span>':'<span>'+s+'</span>';}}
11425 var tz;try{{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{tz='America/Los_Angeles';}}
11426 var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
11427 function ptRef(pt,i){{return pt.tags||(pt.branch?(pt.commit?pt.branch+' @ '+pt.commit.slice(0,7):pt.branch):(pt.commit?pt.commit.slice(0,12):'Scan '+(i+1)));}}
11428 var commitsList=POINTS.map(function(pt,i){{return esc(ptRef(pt,i));}}).join(', ');
11429 var p0=N>0?POINTS[0]:null,pLast=N>0?POINTS[N-1]:null;
11430 var codeDelta=(p0&&pLast)?Number(pLast.code)-Number(p0.code):null;
11431 // Header/footer flow in document order (NOT position:fixed) — a fixed
11432 // header repeats every printed page in Chromium and overlaps the content
11433 // below it, swallowing the first rows of pages 2+ and clipping the cards
11434 // on page 1. The table <thead> repeats per page natively, so every row
11435 // stays visible.
11436 var css='body{{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}}'+
11437 '.pdf-header{{-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11438 '.pdf-footer{{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11439 '.page-hdr{{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}}'+
11440 '.ph-brand{{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}}'+
11441 '.ph-brand em{{color:#c45c10;font-style:normal;}}'+
11442 '.ph-title{{font-size:14px;font-weight:600;color:#555;}}'+
11443 '.ph-date{{font-size:11px;color:#888;text-align:right;white-space:nowrap;}}'+
11444 '.info-bar{{background:#1a2035;color:#fff;padding:7px 14px;display:flex;justify-content:space-between;align-items:center;gap:10px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11445 '.ib-name{{font-size:13px;font-weight:800;color:#fff;}}'+
11446 '.ib-right{{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}}'+
11447 '.ftr{{background:#1a2035;color:#7a8b9c;font-size:10px;padding:5px 14px;display:flex;justify-content:space-between;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11448 '.body{{padding:12px 18px 0;}}'+
11449 '.sg{{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}}'+
11450 '.sc{{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}}'+
11451 '.sv{{font-size:18px;font-weight:900;color:#c45c10;}}'+
11452 '.sl{{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}}'+
11453 '.sec{{margin-bottom:10px;}}'+
11454 '.sh{{background:#1a2035;color:#fff;padding:4px 8px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;margin:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11455 'table{{width:100%;border-collapse:collapse;font-size:11px;}}'+
11456 'th{{background:#1a2035;color:#fff;padding:4px 7px;font-size:10px;font-weight:700;text-align:left;letter-spacing:.04em;white-space:nowrap;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11457 'td{{border-bottom:1px solid #eee;padding:3px 7px;vertical-align:middle;}}'+
11458 'tr:nth-child(even) td{{background:#faf8f6;}}';
11459 // ── Metric Progression ────────────────────────────────────────────────
11460 var hasTests=POINTS.some(function(pt){{return pt.tests!=null&&Number(pt.tests)>0;}});
11461 var hasCov=POINTS.some(function(pt){{return pt.cov!=null;}});
11462 var progHdr='<th>#</th><th>Scan Ref</th><th style="text-align:right">Code Lines</th><th style="text-align:right">Comments</th><th style="text-align:right">Blank Lines</th><th style="text-align:right">Files</th>';
11463 if(hasTests)progHdr+='<th style="text-align:right">Tests</th>';
11464 if(hasCov)progHdr+='<th style="text-align:right">Coverage</th>';
11465 var progRows=POINTS.map(function(pt,i){{
11466 var lbl=pt.tags||(pt.branch?(pt.commit?pt.branch+' @ '+pt.commit.slice(0,8):pt.branch):(pt.commit?pt.commit.slice(0,12):'Scan '+(i+1)));
11467 var r='<tr><td style="text-align:center;font-weight:700">'+(i+1)+'</td><td>'+esc(lbl)+'</td>'+
11468 '<td style="text-align:right">'+full(pt.code)+'</td>'+
11469 '<td style="text-align:right">'+full(pt.comments)+'</td>'+
11470 '<td style="text-align:right">'+full(pt.blank)+'</td>'+
11471 '<td style="text-align:right">'+full(pt.files)+'</td>';
11472 if(hasTests)r+='<td style="text-align:right">'+(pt.tests!=null&&Number(pt.tests)>0?full(pt.tests):'—')+'</td>';
11473 if(hasCov)r+='<td style="text-align:right">'+(pt.cov!=null?Number(pt.cov).toFixed(1)+'%':'—')+'</td>';
11474 return r+'</tr>';
11475 }}).join('');
11476 // ── Scan-to-scan changes ──────────────────────────────────────────────
11477 var deltaRows=N>1?POINTS.slice(1).map(function(pt,i){{
11478 var prev=POINTS[i];
11479 var cd=Number(pt.code)-Number(prev.code),cm=Number(pt.comments)-Number(prev.comments);
11480 var bl=Number(pt.blank)-Number(prev.blank),fd=Number(pt.files)-Number(prev.files);
11481 return '<tr><td style="font-weight:700;white-space:nowrap">'+esc(ptRef(prev,i))+' \u2192 '+esc(ptRef(pt,i+1))+'</td>'+
11482 '<td style="text-align:right">'+dHtml(cd)+'</td>'+
11483 '<td style="text-align:right">'+dHtml(cm)+'</td>'+
11484 '<td style="text-align:right">'+dHtml(bl)+'</td>'+
11485 '<td style="text-align:right">'+dHtml(fd)+'</td></tr>';
11486 }}).join(''):'';
11487 // ── File matrix (top 50 by |total delta|) ────────────────────────────
11488 var fmSection='';
11489 if(FILES&&FILES.length){{
11490 // Hard cap on per-scan columns so the table never overflows the page width.
11491 var MAXC=6;var startIdx=N>MAXC?N-MAXC:0;
11492 var topFiles=FILES.slice().sort(function(a,b){{return Math.abs(Number(b.t))-Math.abs(Number(a.t));}});
11493 var fmHdr='<th>File</th><th>Language</th><th>Status</th>';
11494 for(var fi=startIdx;fi<N;fi++)fmHdr+='<th style="text-align:right">Scan '+(fi+1)+'</th>';
11495 fmHdr+='<th style="text-align:right">Total \u0394</th>';
11496 var fmRows=topFiles.map(function(f){{
11497 var ss=f.s==='added'?'style="color:#2a6846;font-weight:700"':f.s==='removed'?'style="color:#b23030;font-weight:700"':'';
11498 var cols='';for(var fi=startIdx;fi<N;fi++)cols+='<td style="text-align:right">'+(f.c[fi]!=null?Number(f.c[fi]).toLocaleString():'—')+'</td>';
11499 cols+='<td style="text-align:right">'+dHtml(Number(f.t))+'</td>';
11500 var sp=f.p.length>55?'\u2026'+f.p.slice(-53):f.p;
11501 return '<tr><td style="font-family:monospace;font-size:10px;word-break:break-all">'+esc(sp)+'</td><td>'+esc(f.l||'')+'</td><td '+ss+'>'+esc(f.s||'')+'</td>'+cols+'</tr>';
11502 }}).join('');
11503 var colNote=N>MAXC?' (latest '+MAXC+' scans shown)':'';
11504 fmSection='<div class="sec"><p class="sh">File Matrix \u2014 All '+FILES.length+' Files'+colNote+'</p>'+
11505 '<table><thead><tr>'+fmHdr+'</tr></thead><tbody>'+fmRows+'</tbody></table></div>';
11506 }}
11507 return '<!DOCTYPE html><html><head><meta charset="utf-8">'+
11508 '<title>OxideSLOC \u2014 Multi-Scan Timeline</title><style>'+css+'</style></head><body>'+
11509 '<div class="pdf-header"><div class="page-hdr"><div class="ph-brand"><em>oxide</em>-sloc</div><div class="ph-title">Multi-Scan Timeline</div><div class="ph-date">'+esc(now)+'</div></div><div class="info-bar"><div><div class="ib-name">{project_label}</div></div><div class="ib-right">{n} scans compared<br>'+commitsList+'</div></div></div>'+
11510
11511 '<div class="body">'+
11512 '<div class="sg">'+
11513 (pLast?'<div class="sc"><div class="sv">'+full(pLast.code)+'</div><div class="sl">Latest Code Lines</div></div>':
11514 '<div class="sc"><div class="sv">—</div><div class="sl">Latest Code Lines</div></div>')+
11515 (pLast?'<div class="sc"><div class="sv">'+full(pLast.files)+'</div><div class="sl">Latest Files</div></div>':
11516 '<div class="sc"><div class="sv">—</div><div class="sl">Latest Files</div></div>')+
11517 (codeDelta!==null?'<div class="sc"><div class="sv" style="'+(codeDelta>0?'color:#2a6846':codeDelta<0?'color:#b23030':'color:#555')+';font-weight:900">'+dStr(codeDelta)+'</div><div class="sl">Net Code Change</div></div>':
11518 '<div class="sc"><div class="sv">—</div><div class="sl">Net Code Change</div></div>')+
11519 '<div class="sc"><div class="sv" style="color:#111">{n}</div><div class="sl">Scans Compared</div></div>'+
11520 '</div>'+
11521 '<div class="sec"><p class="sh">Metric Progression</p>'+
11522 '<table><thead><tr>'+progHdr+'</tr></thead><tbody>'+progRows+'</tbody></table></div>'+
11523 (N>1?'<div class="sec"><p class="sh">Scan-to-Scan Changes</p>'+
11524 '<table><thead><tr><th style="text-align:center">Scans</th>'+
11525 '<th style="text-align:right">Code \u0394</th><th style="text-align:right">Comments \u0394</th>'+
11526 '<th style="text-align:right">Blank \u0394</th><th style="text-align:right">Files \u0394</th>'+
11527 '</tr></thead><tbody>'+deltaRows+'</tbody></table></div>':'')+
11528 fmSection+
11529 '</div>'+
11530 '<div class="pdf-footer"><div class="ftr"><span>oxide-sloc v{version} | AGPL-3.0-or-later</span><span>Multi-Scan Timeline Report</span><span>{project_label} · {n} scans</span></div></div>'+
11531 '</body></html>';
11532 }}
11533 function mcDoPdf(btn){{
11534 window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('pdf'),button:btn}});
11535 }}
11536
11537 var mcHtmlBtn=document.getElementById('mc-export-html-btn');
11538 if(mcHtmlBtn)mcHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcHtmlBtn,mcExportName('html'));}});
11539 var mcTopHtmlBtn=document.getElementById('mc-top-export-html-btn');
11540 if(mcTopHtmlBtn)mcTopHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcTopHtmlBtn,mcExportName('html'));}});
11541 var mcPdfBtn=document.getElementById('mc-export-pdf-btn');
11542 if(mcPdfBtn)mcPdfBtn.addEventListener('click',function(){{mcDoPdf(mcPdfBtn);}});
11543 var mcTopPdfBtn=document.getElementById('mc-top-export-pdf-btn');
11544 if(mcTopPdfBtn)mcTopPdfBtn.addEventListener('click',function(){{mcDoPdf(mcTopPdfBtn);}});
11545 if(location.protocol==='file:'){{
11546 [mcHtmlBtn,mcTopHtmlBtn,document.getElementById('mc-file-html-btn')].forEach(function(b){{if(b){{b.disabled=true;b.style.opacity='0.45';b.style.cursor='not-allowed';b.title='Already viewing an exported HTML file';b.textContent='Export HTML';}}}} );
11547 [mcPdfBtn,mcTopPdfBtn,document.getElementById('mc-file-pdf-btn')].forEach(function(b){{if(b){{b.disabled=true;b.style.opacity='0.45';b.style.cursor='not-allowed';b.title='PDF export requires a running server';b.textContent='Export PDF';}}}} );
11548 }}
11549 }})();
11550 // ── Scan card modal — document-level click delegation (no timing/parse-order deps) ──
11551 (function(){{
11552 function $(id){{return document.getElementById(id);}}
11553 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
11554 function full(n){{if(n==null||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
11555 function dS(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
11556 function dSt(v){{return Number(v)>0?'color:#2a6846;font-weight:700':Number(v)<0?'color:#b23030;font-weight:700':'';}}
11557 function openModal(idx){{
11558 var ov=$('mc-modal-overlay');if(!ov)return;
11559 var titleEl=$('mc-modal-title'),subEl=$('mc-modal-sub'),bodyEl=$('mc-modal-body');
11560 if(idx<0||idx>=N)return;
11561 var pt=POINTS[idx];
11562 titleEl.textContent='Scan '+(idx+1);
11563 var lbl=pt.tags||(pt.branch?(pt.commit?pt.branch+' @ '+pt.commit:pt.branch):(pt.commit||'\u2014'));
11564 subEl.textContent=lbl;
11565 var sHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Metrics</div><div class="mc-modal-stats">'+
11566 '<div class="mc-modal-stat" data-tip="Physical lines of source code that are neither blank nor comment-only. This is the primary SLOC metric used to size the codebase."><div class="mc-modal-stat-val">'+full(pt.code)+'</div><div class="mc-modal-stat-lbl">Code Lines</div></div>'+
11567 '<div class="mc-modal-stat" data-tip="Lines made up of code comments (single-line or block). Documentation within the source that is not executed."><div class="mc-modal-stat-val">'+full(pt.comments)+'</div><div class="mc-modal-stat-lbl">Comments</div></div>'+
11568 '<div class="mc-modal-stat" data-tip="Empty lines or lines containing only whitespace. Counted separately from code and comment lines."><div class="mc-modal-stat-val">'+full(pt.blank)+'</div><div class="mc-modal-stat-lbl">Blank Lines</div></div>'+
11569 '<div class="mc-modal-stat" data-tip="Total number of source files analyzed in this scan across every supported language."><div class="mc-modal-stat-val">'+full(pt.files)+'</div><div class="mc-modal-stat-lbl">Files</div></div>'+
11570 (pt.tests!=null&&Number(pt.tests)>0?'<div class="mc-modal-stat" data-tip="Number of unit-test definitions detected across the scanned files."><div class="mc-modal-stat-val">'+full(pt.tests)+'</div><div class="mc-modal-stat-lbl">Tests</div></div>':'')+
11571 (pt.cov!=null?'<div class="mc-modal-stat" data-tip="Percentage of code lines covered by tests for this scan, shown when coverage results were captured."><div class="mc-modal-stat-val">'+Number(pt.cov).toFixed(1)+'%</div><div class="mc-modal-stat-lbl">Coverage</div></div>':'')+
11572 '</div></div>';
11573 var iHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Scan Info</div>'+
11574 (pt.commit?'<div class="mc-modal-row"><span class="mc-modal-key">Commit</span><span class="mc-modal-val"><a href="/runs/html/'+esc(pt.run_id)+'" target="_blank" rel="noopener">'+esc(pt.commit)+'</a></span></div>':'')+
11575 (pt.branch?'<div class="mc-modal-row"><span class="mc-modal-key">Branch</span><span class="mc-modal-val">'+esc(pt.branch)+'</span></div>':'')+
11576 (pt.tags?'<div class="mc-modal-row"><span class="mc-modal-key">Tags</span><span class="mc-modal-val">'+esc(pt.tags)+'</span></div>':'')+
11577 (pt.nearest?'<div class="mc-modal-row"><span class="mc-modal-key">Nearest tag</span><span class="mc-modal-val">'+esc(pt.nearest)+'</span></div>':'')+
11578 (pt.commit_date?'<div class="mc-modal-row"><span class="mc-modal-key">Last commit on</span><span class="mc-modal-val">'+esc(pt.commit_date)+'</span></div>':'')+
11579 (pt.author?'<div class="mc-modal-row"><span class="mc-modal-key">Last commit by</span><span class="mc-modal-val">'+esc(pt.author)+'</span></div>':'')+
11580 (pt.scanned?'<div class="mc-modal-row"><span class="mc-modal-key">Scanned on</span><span class="mc-modal-val">'+esc(pt.scanned)+'</span></div>':'')+
11581 '<div class="mc-modal-row"><span class="mc-modal-key">Run ID</span><span class="mc-modal-val"><a href="/runs/html/'+esc(pt.run_id)+'" target="_blank" rel="noopener">'+esc(pt.run_id)+'</a></span></div>'+
11582 '</div>';
11583 var dHtml='';
11584 if(idx>0){{
11585 var prev=POINTS[idx-1];
11586 var cd=Number(pt.code)-Number(prev.code),fd=Number(pt.files)-Number(prev.files),cm=Number(pt.comments)-Number(prev.comments);
11587 dHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Change vs Scan '+idx+'</div><div class="mc-modal-stats">'+
11588 '<div class="mc-modal-stat" data-tip="Net change in code lines compared with the previous scan in this timeline. Green is an increase, red a decrease."><div class="mc-modal-stat-val" style="'+dSt(cd)+'">'+dS(cd)+'</div><div class="mc-modal-stat-lbl">Code \u0394</div></div>'+
11589 '<div class="mc-modal-stat" data-tip="Net change in the number of analyzed files compared with the previous scan."><div class="mc-modal-stat-val" style="'+dSt(fd)+'">'+dS(fd)+'</div><div class="mc-modal-stat-lbl">Files \u0394</div></div>'+
11590 '<div class="mc-modal-stat" data-tip="Net change in comment lines compared with the previous scan."><div class="mc-modal-stat-val" style="'+dSt(cm)+'">'+dS(cm)+'</div><div class="mc-modal-stat-lbl">Comments \u0394</div></div>'+
11591 '</div></div>';
11592 }}
11593 bodyEl.innerHTML=sHtml+iHtml+dHtml;
11594 ov.classList.add('open');document.body.style.overflow='hidden';
11595 }}
11596 function closeModal(){{var ov=$('mc-modal-overlay');if(ov)ov.classList.remove('open');document.body.style.overflow='';}}
11597 // Delegated click: robust to parse order, re-renders, and missing-at-attach elements.
11598 document.addEventListener('click',function(e){{
11599 if(!e.target||!e.target.closest)return;
11600 if(e.target.closest('#mc-modal-close')){{closeModal();return;}}
11601 if(e.target.id==='mc-modal-overlay'){{closeModal();return;}}
11602 var card=e.target.closest('.mc-card');
11603 if(!card)return;
11604 if(e.target.closest('a'))return;
11605 var cards=Array.prototype.slice.call(document.querySelectorAll('.mc-card'));
11606 var i=cards.indexOf(card);
11607 if(i>=0)openModal(i);
11608 }});
11609 document.addEventListener('keydown',function(e){{if(e.key==='Escape')closeModal();}});
11610 // Styled hover description for the metric boxes (fixed tooltip, never clipped by the modal scroll area).
11611 var statTip=null;
11612 document.addEventListener('mousemove',function(e){{
11613 var box=(e.target&&e.target.closest)?e.target.closest('.mc-modal-stat[data-tip]'):null;
11614 if(!box){{if(statTip)statTip.style.display='none';return;}}
11615 if(!statTip){{statTip=document.createElement('div');statTip.id='mc-stat-tt';document.body.appendChild(statTip);}}
11616 var tip=box.getAttribute('data-tip')||'';
11617 if(statTip.textContent!==tip)statTip.textContent=tip;
11618 statTip.style.display='block';
11619 var w=statTip.offsetWidth,h=statTip.offsetHeight,x=e.clientX+14,y=e.clientY+16;
11620 if(x+w>window.innerWidth-8)x=e.clientX-w-14;
11621 if(y+h>window.innerHeight-8)y=e.clientY-h-16;
11622 statTip.style.left=(x<8?8:x)+'px';statTip.style.top=(y<8?8:y)+'px';
11623 }});
11624 (function tagCards(){{var cs=document.querySelectorAll('.mc-card');for(var k=0;k<cs.length;k++)cs[k].setAttribute('title','Click to view full scan details');}})();
11625 }})();
11626 }})();
11627 </script>
11628 <script nonce="{csp_nonce}">(function(){{var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';
11629 if(location.protocol==='file:'){{if(lbl)lbl.textContent='Offline';if(dot){{dot.style.background='#888';dot.style.boxShadow='none';}}if(pingEl)pingEl.textContent='';var td=document.querySelector('.server-status-tip');if(td)td.textContent='Saved HTML report \u2014 server not connected.';return;}}
11630 if(lbl)lbl.textContent=isServer?'Server':'Local';function setDot(ms){{if(!dot)return;if(ms<100){{dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}}else if(ms<300){{dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}}else{{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}}function doPing(){{var t0=performance.now();fetch('/healthz',{{cache:'no-store'}}).then(function(){{var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}}).catch(function(){{if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}});}}doPing();setInterval(doPing,5000);}})();</script>
11631 <!-- Scan card detail modal -->
11632 <div class="mc-modal-overlay" id="mc-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="mc-modal-title">
11633 <div class="mc-modal" id="mc-modal">
11634 <div class="mc-modal-head">
11635 <div><div class="mc-modal-title" id="mc-modal-title">Scan</div><div class="mc-modal-sub" id="mc-modal-sub"></div></div>
11636 <button class="mc-modal-close" id="mc-modal-close" aria-label="Close">✕</button>
11637 </div>
11638 <div class="mc-modal-body" id="mc-modal-body"></div>
11639 </div>
11640 </div>
11641 {toast_assets}
11642</body>
11643</html>"#,
11644 project_label = html_escape(project_label),
11645 n = n,
11646 scan_strip = scan_strip,
11647 mc_strip_class = mc_strip_class,
11648 metrics_thead = metrics_thead,
11649 metrics_tbody = metrics_tbody,
11650 file_col_headers = file_col_headers,
11651 total_files = total_files,
11652 files_modified = files_modified,
11653 files_added = files_added,
11654 files_removed = files_removed,
11655 files_unchanged = files_unchanged,
11656 points_json = points_json,
11657 file_matrix_json = file_matrix_json,
11658 nav_compare_active = nav_compare_active,
11659 version = version,
11660 csp_nonce = csp_nonce,
11661 scope_bar_html = scope_bar_html,
11662 scope_label = scope_label,
11663 loading_overlay = loading_overlay_block(csp_nonce, "Loading comparison"),
11664 )
11665}
11666
11667#[allow(clippy::too_many_lines)] async fn trend_report_handler(
11675 State(state): State<AppState>,
11676 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
11677) -> Response {
11678 auto_scan_watched_dirs(&state).await;
11679
11680 let watched_dirs_list: Vec<String> = {
11681 let wd = state.watched_dirs.lock().await;
11682 wd.dirs.iter().map(|p| p.display().to_string()).collect()
11683 };
11684
11685 let roots: Vec<String> = {
11687 let reg = state.registry.lock().await;
11688 let mut seen = std::collections::BTreeSet::new();
11689 reg.entries
11690 .iter()
11691 .flat_map(|e| e.input_roots.iter().cloned())
11692 .filter(|r| seen.insert(r.clone()))
11693 .collect()
11694 };
11695
11696 let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
11697 let nonce = &csp_nonce;
11698 let version = env!("CARGO_PKG_VERSION");
11699 let toast_assets = sloc_toast_assets(nonce);
11700
11701 let watched_dirs_html: String = if state.server_mode {
11705 r#"<div class="watched-bar"><div class="watched-bar-left"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="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><span class="watched-label">Watched Folders</span><div class="watched-chips"><span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span></div></div></div>"#.to_string()
11706 } else {
11707 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
11708 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
11709 .to_string()
11710 } else {
11711 watched_dirs_list
11712 .iter()
11713 .fold(String::new(), |mut s, d| {
11714 use std::fmt::Write as _;
11715 let escaped =
11716 d.replace('&', "&").replace('"', """).replace('<', "<");
11717 write!(
11718 s,
11719 r#"<span class="watched-chip"><span class="watched-chip-path" title="{escaped}">{escaped}</span><form method="POST" action="/watched-dirs/remove" style="display:contents"><input type="hidden" name="folder_path" value="{escaped}"><input type="hidden" name="redirect_to" value="/trend-reports"><button type="submit" class="watched-chip-rm" title="Remove folder">✕</button></form></span>"#
11720 ).expect("write to String is infallible");
11721 s
11722 })
11723 };
11724 format!(
11725 r#"<div class="watched-bar" id="watched-bar"><div class="watched-bar-left"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="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><span class="watched-label">Watched Folders</span><div class="watched-chips">{watched_dirs_chips}</div></div><div class="watched-bar-right"><button type="button" class="btn" id="add-watched-btn"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg> Choose</button><form method="POST" action="/watched-dirs/refresh" style="display:contents"><input type="hidden" name="redirect_to" value="/trend-reports"><button type="submit" class="btn">↻ Refresh</button></form></div></div>"#
11726 )
11727 };
11728
11729 let html = format!(
11730 r##"<!doctype html>
11731<html lang="en">
11732<head>
11733 <meta charset="utf-8" />
11734 <meta name="viewport" content="width=device-width, initial-scale=1" />
11735 <title>OxideSLOC | Trend Reports</title>
11736 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
11737 <style nonce="{nonce}">
11738 :root {{
11739 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
11740 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
11741 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
11742 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
11743 --info-bg:#eef3ff; --info-text:#4467d8;
11744 }}
11745 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
11746 *{{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);}} body{{display:flex;flex-direction:column;}}
11747 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
11748 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
11749 .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;}}
11750 @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));}}}}
11751 .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);}}
11752 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
11753 .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}} .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));}}
11754 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
11755 .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;white-space:nowrap;}}
11756 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
11757 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
11758 @media (max-width:1150px) {{ .nav-right {{ gap:4px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 8px;font-size:11px;min-height:34px; }} .brand-subtitle {{ display:none; }} .server-online-pill {{ width:34px;padding:0;justify-content:center;font-size:0;gap:0;min-height:34px; }} }}
11759 .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;white-space:nowrap;text-decoration:none;transition:background .15s ease,transform .15s ease;}}
11760 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
11761 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
11762 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
11763 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
11764 .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;}}
11765 .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;}}
11766 .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;white-space:nowrap;text-decoration:none;}}.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;}}
11767 .settings-modal{{position:fixed;z-index:9999;background:var(--surface-2);border:1px solid var(--line-strong);border-radius:14px;box-shadow:0 12px 36px rgba(0,0,0,0.22);min-width:260px;max-width:320px;opacity:0;pointer-events:none;transform:translateY(-8px) scale(0.97);transition:opacity 0.18s ease,transform 0.18s ease;overflow:hidden;}}
11768 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
11769 .settings-modal-header{{display:flex;align-items:center;justify-content:space-between;padding:14px 16px 10px;border-bottom:1px solid var(--line);font-size:13px;font-weight:800;color:var(--text);}}
11770 .settings-close{{background:none;border:none;cursor:pointer;width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--muted);border-radius:6px;padding:0;}}
11771 .settings-close:hover{{color:var(--text);background:var(--surface-2);}} .settings-close svg{{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}}
11772 .settings-modal-body{{padding:14px 16px 16px;}} .settings-modal-label{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}}
11773 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
11774 .scheme-swatch{{display:flex;flex-direction:column;align-items:center;gap:5px;background:none;border:1.5px solid var(--line);border-radius:10px;cursor:pointer;padding:7px 4px 6px;transition:border-color 0.15s ease,transform 0.12s ease;}}
11775 .scheme-swatch:hover{{border-color:var(--line-strong);transform:translateY(-1px);}} .scheme-swatch.active{{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}}
11776 .scheme-preview{{width:28px;height:28px;border-radius:7px;flex-shrink:0;}} .scheme-label{{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}}
11777 .tz-select{{width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2);color:var(--text);font-size:12px;font-weight:600;cursor:pointer;outline:none;box-sizing:border-box;}}
11778 .tz-select:focus{{border-color:var(--oxide);}}
11779 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
11780 @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
11781 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
11782 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
11783 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
11784 .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
11785 .trend-title-block{{flex:1;min-width:0;}}
11786 .controls-centered{{display:flex;justify-content:center;align-items:center;gap:20px;flex-wrap:wrap;padding:13px 0 15px;border-top:1px solid var(--line);border-bottom:1px solid var(--line);margin-bottom:16px;}}
11787 .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
11788 .chart-select{{background:var(--surface-2);border:1px solid var(--line-strong);border-radius:8px;padding:5px 10px;color:var(--text);font-size:13px;font-weight:600;cursor:pointer;outline:none;}}
11789 .chart-select:focus{{border-color:var(--accent);}}
11790 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
11791 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
11792 .stat-chip{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px 16px;position:relative;cursor:default;transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1);}}
11793 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
11794 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
11795 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
11796 .stat-chip-tip{{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%) translateY(-7px);background:var(--text);color:var(--bg);padding:7px 12px;border-radius:8px;font-size:11px;font-weight:500;line-height:1.6;white-space:normal;max-width:280px;pointer-events:none;opacity:0;transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1);z-index:200;box-shadow:0 4px 14px rgba(0,0,0,0.2);}}
11797 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
11798 .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
11799 .stat-chip-exact{{position:absolute;bottom:6px;right:10px;font-size:12px;font-weight:600;color:var(--muted);font-variant-numeric:tabular-nums;line-height:1;}}
11800 .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
11801 body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
11802 .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
11803 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
11804 .tr-expand-btn{{background:none;border:1px solid var(--line-strong);border-radius:6px;cursor:pointer;color:var(--muted);padding:4px 10px;font-size:13px;line-height:1;transition:background .13s,color .13s;white-space:nowrap;}}
11805 .tr-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
11806 .tr-chart-full-modal{{position:fixed;inset:0;background:rgba(0,0,0,0.55);z-index:9999;display:flex;align-items:center;justify-content:center;padding:24px;box-sizing:border-box;}}
11807 .tr-chart-full-inner{{background:var(--bg);border-radius:16px;padding:24px 28px;max-width:1600px;width:100%;max-height:90vh;overflow-y:auto;position:relative;box-shadow:0 24px 80px rgba(0,0,0,0.3);}}
11808 .chart-hint-inline{{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--muted);font-weight:600;white-space:nowrap;margin-top:8px;}}
11809 .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
11810 .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
11811 .chart-section-header{{font-size:13px;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.07em;margin:22px 0 10px;padding-top:16px;border-top:1px solid var(--line);}}
11812 .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
11813 .data-table 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;overflow:hidden;text-overflow:ellipsis;position:relative;user-select:none;}}
11814 .data-table td{{text-align:left;padding:10px 12px;border-bottom:1px solid var(--line);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle;}}
11815 .data-table tr:last-child td{{border-bottom:none;}}
11816 .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
11817 .num{{text-align:right;font-variant-numeric:tabular-nums;}}
11818 .table-wrap{{width:100%;overflow-x:auto;}}
11819 .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
11820 .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
11821 .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
11822 .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
11823 .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
11824 .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
11825 .filter-input{{border:1px solid var(--line-strong);border-radius:8px;background:var(--surface-2);color:var(--text);padding:5px 10px;font-size:13px;cursor:text;min-width:180px;}}
11826 .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;}}
11827 .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
11828 .pagination-info{{font-size:13px;color:var(--muted);}}
11829 .pagination-btns{{display:flex;gap:6px;}}
11830 .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;}}
11831 .pg-btn:hover{{background:var(--line);}} .pg-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}} .pg-btn:disabled{{opacity:.35;cursor:default;pointer-events:none;}}
11832 #scan-history-table col:nth-child(1){{width:155px;}}
11833 #scan-history-table col:nth-child(2){{width:240px;}}
11834 #scan-history-table col:nth-child(3){{width:82px;}}
11835 #scan-history-table col:nth-child(4){{width:82px;}}
11836 #scan-history-table col:nth-child(5){{width:90px;}}
11837 #scan-history-table col:nth-child(6){{width:90px;}}
11838 #scan-history-table col:nth-child(7){{width:88px;}}
11839 #scan-history-table col:nth-child(8){{width:150px;}}
11840 #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
11841 .tag-chip{{display:inline-flex;padding:2px 8px;border-radius:999px;background:var(--info-bg);color:var(--info-text);font-size:11px;font-weight:700;margin-right:4px;}}
11842 .watched-bar{{display:flex;align-items:center;gap:10px;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:8px 12px;flex-wrap:wrap;margin-bottom:14px;position:relative;z-index:1;}}
11843 .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
11844 .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
11845 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
11846 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
11847 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
11848 .watched-chip{{display:inline-flex;align-items:center;gap:4px;background:var(--surface-2);border:1px solid var(--line);border-radius:6px;padding:3px 6px 3px 8px;font-size:11px;max-width:300px;}}
11849 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
11850 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
11851 .watched-chip-rm:hover{{color:var(--oxide);}}
11852 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
11853 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
11854 .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
11855 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
11856 .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
11857 a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
11858 a.run-link:hover{{text-decoration:underline;}}
11859 .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);}}
11860 .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);}}
11861 body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
11862 .metric-num{{font-weight:700;color:var(--text);}}
11863 .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
11864 .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;}}
11865 .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
11866 .btn.primary:hover{{opacity:.9;}}
11867 .rpt-btn{{min-width:58px;justify-content:center;}}
11868 .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
11869 .report-cell{{overflow:visible!important;white-space:normal!important;}}
11870 .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
11871 .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
11872 .submod-details summary::-webkit-details-marker{{display:none;}}
11873 .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
11874 .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;}}
11875 .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
11876 body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
11877 .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
11878 .export-btn{{display:inline-flex;align-items:center;gap:5px;padding:5px 13px;border-radius:7px;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap;transition:background .12s ease;text-decoration:none;}}
11879 .export-btn:hover{{background:var(--line);}}
11880 .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
11881 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
11882 .site-footer a{{color:var(--muted);}}
11883 .loading-state{{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:52px 24px;gap:14px;color:var(--muted);font-size:13px;font-weight:600;}}
11884 .loading-spinner{{width:30px;height:30px;border:3px solid var(--line);border-top-color:var(--oxide);border-radius:50%;animation:spin-load 0.75s linear infinite;}}
11885 @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
11886 /* Modal system (Retention Policy / Clean-up) */
11887 .tr-modal-backdrop{{display:none;position:fixed;inset:0;z-index:9000;background:rgba(40,24,12,0.34);backdrop-filter:blur(2px);-webkit-backdrop-filter:blur(2px);align-items:center;justify-content:center;padding:24px;animation:tr-fade .16s ease;}}
11888 @keyframes tr-fade{{from{{opacity:0;}}to{{opacity:1;}}}}
11889 .tr-modal{{background:var(--surface);border:1px solid var(--line-strong);border-radius:18px;box-shadow:0 28px 70px rgba(40,24,12,0.32),0 4px 14px rgba(40,24,12,0.16);width:100%;max-height:92vh;overflow-y:auto;animation:tr-pop .18s cubic-bezier(.2,.9,.3,1.2);}}
11890 .tr-modal{{background:rgba(255,255,255,0.90);}}
11891 body.dark-theme .tr-modal{{background:rgba(38,28,23,0.90);}}
11892 @keyframes tr-pop{{from{{transform:translateY(14px) scale(.97);opacity:0;}}to{{transform:none;opacity:1;}}}}
11893 .tr-modal-head{{display:flex;align-items:center;gap:14px;padding:24px 30px 18px;border-bottom:1px solid var(--line);}}
11894 .tr-modal-icon{{flex:none;width:44px;height:44px;border-radius:12px;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#e07b3a,#b85028);box-shadow:0 4px 12px rgba(184,80,40,0.32);}}
11895 .tr-modal-icon svg{{width:23px;height:23px;stroke:#fff;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}}
11896 .tr-modal-icon.danger{{background:linear-gradient(135deg,#d65a5a,#b23030);box-shadow:0 4px 12px rgba(178,48,48,0.32);}}
11897 .tr-modal-title{{font-size:21px;font-weight:900;letter-spacing:-.01em;color:var(--text);margin:0;line-height:1.15;}}
11898 .tr-modal-sub{{font-size:12.5px;color:var(--muted);margin:2px 0 0;line-height:1.4;}}
11899 .tr-modal-body{{padding:22px 30px;}}
11900 .tr-modal-foot{{display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;padding:18px 30px 24px;border-top:1px solid var(--line);}}
11901 .tr-btn{{display:inline-flex;align-items:center;justify-content:center;gap:7px;padding:11px 20px;border-radius:10px;font-size:13.5px;font-weight:800;cursor:pointer;border:1px solid transparent;transition:transform .12s ease,box-shadow .12s ease,background .12s ease,opacity .12s ease;font-family:inherit;line-height:1;}}
11902 .tr-btn:hover{{transform:translateY(-1px);}}
11903 .tr-btn:active{{transform:translateY(0);}}
11904 .tr-btn:disabled{{opacity:.55;cursor:not-allowed;transform:none;}}
11905 .tr-btn svg{{width:15px;height:15px;stroke:currentColor;fill:none;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;}}
11906 .tr-btn-primary{{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;box-shadow:0 4px 14px rgba(184,80,40,0.28);}}
11907 .tr-btn-primary:hover{{box-shadow:0 7px 20px rgba(184,80,40,0.38);}}
11908 .tr-btn-secondary{{background:var(--surface-2);color:var(--text);border-color:var(--line-strong);}}
11909 .tr-btn-secondary:hover{{background:var(--line);}}
11910 .tr-btn-danger{{background:linear-gradient(135deg,#d65a5a,#b23030);color:#fff;box-shadow:0 4px 14px rgba(178,48,48,0.28);}}
11911 .tr-btn-danger:hover{{box-shadow:0 7px 20px rgba(178,48,48,0.4);}}
11912 </style>
11913</head>
11914<body>
11915 <div class="background-watermarks" aria-hidden="true">
11916 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11917 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11918 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11919 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11920 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11921 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11922 </div>
11923 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
11924 <div class="top-nav">
11925 <div class="top-nav-inner">
11926 <a class="brand" href="/">
11927 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
11928 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
11929 </a>
11930 <div class="nav-right">
11931 <a class="nav-pill" href="/">Home</a>
11932 <div class="nav-dropdown">
11933 <a href="/view-reports" class="nav-dropdown-btn" style="background:rgba(255,255,255,0.22);">View Reports <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></a>
11934 <div class="nav-dropdown-menu">
11935 <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
11936 </div>
11937 </div>
11938 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
11939 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
11940 <div class="nav-dropdown">
11941 <a href="/git-browser" class="nav-dropdown-btn">Git Browser <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></a>
11942 <div class="nav-dropdown-menu">
11943 <a href="/integrations"><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>Integrations</a>
11944 </div>
11945 </div>
11946 <div class="server-status-wrap" id="server-status-wrap">
11947 <div class="nav-pill server-online-pill" id="server-status-pill">
11948 <span class="status-dot" id="status-dot"></span>
11949 <span id="server-status-label">Server</span>
11950 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
11951 </div>
11952 <div class="server-status-tip">
11953 OxideSLOC is running — accessible on your network.
11954 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
11955 </div>
11956 </div>
11957 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
11958 <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
11959 </button>
11960 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
11961 <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>
11962 <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>
11963 </button>
11964 </div>
11965 </div>
11966 </div>
11967
11968 <div class="page">
11969 {watched_dirs_html}
11970 <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
11971 <div class="scan-overlay-card">
11972 <div class="scan-spinner"></div>
11973 <div class="scan-overlay-text">Scanning folder…</div>
11974 <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
11975 </div>
11976 </div>
11977 <style>
11978 .scan-overlay{{position:fixed;inset:0;z-index:12000;display:none;align-items:center;justify-content:center;background:rgba(20,12,8,0.5);backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);}}
11979 .scan-overlay.active{{display:flex;}}
11980 .scan-overlay-card{{background:var(--surface);border:1px solid var(--line-strong);border-radius:16px;padding:26px 38px;display:flex;flex-direction:column;align-items:center;gap:12px;box-shadow:0 24px 60px rgba(0,0,0,0.35);max-width:340px;text-align:center;}}
11981 .scan-spinner{{width:42px;height:42px;border-radius:50%;border:4px solid var(--line);border-top-color:var(--oxide);animation:scanSpin 0.8s linear infinite;}}
11982 @keyframes scanSpin{{to{{transform:rotate(360deg);}}}}
11983 .scan-overlay-text{{font-size:15px;font-weight:800;color:var(--text);}}
11984 .scan-overlay-sub{{font-size:12px;color:var(--muted);line-height:1.5;}}
11985 </style>
11986 <div class="summary-strip" id="trend-stats"></div>
11987 <div class="panel">
11988 <div class="trend-header">
11989 <div class="trend-title-block">
11990 <h1>Trend Reports</h1>
11991 <p class="muted">Plot any SLOC metric over time. Each data point is a saved scan. Select a project root,<br>choose a metric and X-axis mode, then explore how your codebase has changed across commits, tags, or time.</p>
11992 <span class="chart-hint-inline">
11993 <svg viewBox="0 0 24 24"><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>
11994 Click a dot or row to view its full report · <span class="dot" style="background:#C45C10;"></span> regular scan <span class="dot" style="background:#4472C4;"></span> tagged / release scan
11995 </span>
11996 </div>
11997 <div class="chart-actions">
11998 <button type="button" class="export-btn" id="retention-policy-btn" title="Configure automatic cleanup of old scan runs">
11999 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
12000 Retention Policy
12001 </button>
12002 <button type="button" class="export-btn" id="cleanup-runs-btn" title="Delete scans older than a chosen number of days">
12003 <svg viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
12004 Clean up old runs
12005 </button>
12006 <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
12007 <svg viewBox="0 0 24 24"><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>
12008 Export Excel
12009 </button>
12010 <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
12011 <svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
12012 Export PNG
12013 </button>
12014 <button type="button" class="export-btn" id="export-pdf-btn" title="Open a print-ready PDF report (chart + summary + table)">
12015 <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"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="13" y2="17"/></svg>
12016 Export PDF
12017 </button>
12018 </div>
12019 </div>
12020
12021 <div class="controls-centered">
12022 <label>Project Root:
12023 <select class="chart-select" id="root-sel">
12024 <option value="">All projects</option>
12025 </select>
12026 </label>
12027 <label>Y Metric:
12028 <select class="chart-select" id="y-sel">
12029 <option value="code_lines">Code Lines</option>
12030 <option value="comment_lines">Comment Lines</option>
12031 <option value="blank_lines">Blank Lines</option>
12032 <option value="physical_lines">Physical Lines</option>
12033 <option value="files_analyzed">Files Analyzed</option>
12034 </select>
12035 </label>
12036 <label>X Axis:
12037 <select class="chart-select" id="x-sel">
12038 <option value="time">By Time</option>
12039 <option value="commit" selected>By Commit</option>
12040 <option value="release">By Release</option>
12041 <option value="tag">Tagged Commits</option>
12042 </select>
12043 </label>
12044 <label id="submodule-label" style="display:none;">Submodule:
12045 <select class="chart-select" id="sub-sel">
12046 <option value="">All (project total)</option>
12047 </select>
12048 </label>
12049 <label>Chart Size:
12050 <select class="chart-select" id="scale-sel">
12051 <option value="0.75">Compact</option>
12052 <option value="1.2" selected>Normal</option>
12053 <option value="1.38">Large</option>
12054 </select>
12055 </label>
12056 <button class="tr-expand-btn" id="tr-chart-fv-btn">⤢ Full View</button>
12057 </div>
12058
12059 <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history…</div></div>
12060 <div id="data-table-wrap" style="overflow-x:auto;"></div>
12061 </div>
12062 </div>
12063
12064 <script nonce="{nonce}">
12065 (function() {{
12066 // Theme persistence
12067 var b = document.body;
12068 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
12069 var tgl = document.getElementById('theme-toggle');
12070 if (tgl) tgl.addEventListener('click', function() {{
12071 var d = b.classList.toggle('dark-theme');
12072 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
12073 }});
12074
12075 // Watermark randomizer
12076 (function() {{
12077 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
12078 if (!wms.length) return;
12079 var placed = [];
12080 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;}}
12081 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];}}
12082 var half=Math.floor(wms.length/2);
12083 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;}});
12084 }})();
12085
12086 // Code particles
12087 (function() {{
12088 var container = document.getElementById('code-particles');
12089 if (!container) return;
12090 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'];
12091 for (var i = 0; i < 38; i++) {{
12092 (function(idx) {{
12093 var el = document.createElement('span');
12094 el.className = 'code-particle';
12095 el.textContent = snippets[idx % snippets.length];
12096 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
12097 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
12098 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
12099 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';
12100 container.appendChild(el);
12101 }})(i);
12102 }}
12103 }})();
12104
12105 // Watched folder picker
12106 (function(){{
12107 window.__scanOverlay=function(msg){{var o=document.getElementById('scan-overlay');if(!o)return;if(o.parentNode!==document.body)document.body.appendChild(o);var t=o.querySelector('.scan-overlay-text');if(t&&msg)t.textContent=msg;o.classList.add('active');}};
12108 document.addEventListener('submit',function(e){{var f=e.target;if(!f||!f.getAttribute)return;var a=f.getAttribute('action')||'';if(a.indexOf('/watched-dirs/remove')!==-1){{window.__scanOverlay('Updating watched folders');}}else if(a.indexOf('/watched-dirs/')!==-1){{window.__scanOverlay();}}}},true);
12109 }})();
12110 (function() {{
12111 var btn = document.getElementById('add-watched-btn');
12112 if (!btn) return;
12113 btn.addEventListener('click', function() {{
12114 fetch('/pick-directory?kind=reports')
12115 .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
12116 .then(function(data) {{
12117 if (!data.cancelled && data.selected_path) {{
12118 var form = document.createElement('form');
12119 form.method = 'POST';
12120 form.action = '/watched-dirs/add';
12121 var ri = document.createElement('input');
12122 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
12123 var fi = document.createElement('input');
12124 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
12125 form.appendChild(ri); form.appendChild(fi);
12126 document.body.appendChild(form);
12127 if (window.__scanOverlay) window.__scanOverlay();
12128 form.submit();
12129 }}
12130 }})
12131 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
12132 }});
12133 }})();
12134
12135 // Settings / color-scheme modal
12136 (function() {{
12137 var S=[{{n:'Classic',a:'#b85d33',b:'#7a371b'}},{{n:'Navy',a:'#283790',b:'#1e1e24'}},{{n:'Ember',a:'#ce5d3d',b:'#1e1e24'}},{{n:'Ocean',a:'#1f439b',b:'#1e1e24'}},{{n:'Royal',a:'#003184',b:'#1e1e24'}}];
12138 function ap(s){{document.documentElement.style.setProperty('--nav',s.a);document.documentElement.style.setProperty('--nav-2',s.b);try{{localStorage.setItem('sloc-ns',JSON.stringify(s));}}catch(e){{}}document.querySelectorAll('.scheme-swatch').forEach(function(x){{x.classList.toggle('active',x.dataset.n===s.n);}});}}
12139 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
12140 var btn=document.getElementById('settings-btn');if(!btn)return;
12141 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
12142 m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
12143 document.body.appendChild(m);
12144 var g=document.getElementById('scheme-grid');
12145 if(g)S.forEach(function(s){{var el=document.createElement('button');el.type='button';el.className='scheme-swatch';el.dataset.n=s.n;el.title=s.n;var p=document.createElement('div');p.className='scheme-preview';p.style.background='linear-gradient(135deg,'+s.a+','+s.b+')';var l=document.createElement('span');l.className='scheme-label';l.textContent=s.n;el.appendChild(p);el.appendChild(l);try{{var c=JSON.parse(localStorage.getItem('sloc-ns'));if(c&&c.n===s.n)el.classList.add('active');}}catch(e){{}}el.addEventListener('click',function(){{ap(s);}});g.appendChild(el);}});
12146 var cl=document.getElementById('settings-close');
12147 window.tzAbbr=function(z){{return{{'America/Los_Angeles':'PT','America/Denver':'MT','America/Chicago':'CT','America/New_York':'ET','America/Anchorage':'AT','Pacific/Honolulu':'HT'}}[z]||'PT';}};window.tzCity=function(z){{return{{'America/Los_Angeles':'Los Angeles','America/Denver':'Denver','America/Chicago':'Chicago','America/New_York':'New York','America/Anchorage':'Anchorage','Pacific/Honolulu':'Honolulu'}}[z]||'';}};window.tzOffset=function(z){{var r='';try{{var p=new Intl.DateTimeFormat('en-US',{{timeZone:z,timeZoneName:'longOffset'}}).formatToParts(new Date());p.forEach(function(x){{if(x.type==='timeZoneName')r=x.value.replace('GMT','UTC');}});}}catch(e){{}}return r;}};window.tf24=function(){{try{{return localStorage.getItem('sloc-tf')!=='12';}}catch(e){{return true;}}}};window.fmtTz=function(ms,tz){{var d=new Date(ms);if(isNaN(d.getTime()))return'';var h24=window.tf24();try{{var pts=new Intl.DateTimeFormat('en-US',{{timeZone:tz,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:!h24}}).formatToParts(d);var v={{}};pts.forEach(function(p){{v[p.type]=p.value;}});var t=v.hour+':'+v.minute;if(!h24&&v.dayPeriod)t+=' '+v.dayPeriod;return v.year+'-'+v.month+'-'+v.day+' '+t+' '+window.tzAbbr(tz);}}catch(e){{return'';}}}};window.enhanceTzOptions=function(sel){{if(!sel)return;Array.prototype.forEach.call(sel.options,function(o){{var base=o.textContent.split(' - ')[0];var city=window.tzCity(o.value);var off=window.tzOffset(o.value);o.textContent=base+(city?' - '+city:'')+(off?' - '+off:'');}});}};window.applyTz=function(tz){{try{{localStorage.setItem('sloc-tz',tz);}}catch(e){{}}document.querySelectorAll('[data-utc-ms]').forEach(function(el){{var ms=parseInt(el.getAttribute('data-utc-ms'),10);if(!isNaN(ms))el.textContent=window.fmtTz(ms,tz);}});}};window.applyTf=function(tf){{try{{localStorage.setItem('sloc-tf',tf);}}catch(e){{}}var z;try{{z=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{z='America/Los_Angeles';}}window.applyTz(z);}};var tzSel=document.getElementById('tz-select');window.enhanceTzOptions(tzSel);var storedTz;try{{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{storedTz='America/Los_Angeles';}}if(tzSel){{tzSel.value=storedTz;tzSel.addEventListener('change',function(){{window.applyTz(this.value);}});}}window.applyTz(storedTz);(function(){{var tzp=document.getElementById('tz-select');if(!tzp||document.getElementById('tf-select')||!tzp.parentNode)return;var tw=document.createElement('div');tw.style.marginTop='10px';var tl=document.createElement('div');tl.className='settings-modal-label';tl.style.marginBottom='8px';tl.textContent='Time format';var tfSel=document.createElement('select');tfSel.className='tz-select';tfSel.id='tf-select';tfSel.innerHTML='<option value="24">24-hour (14:30)</option><option value="12">12-hour (2:30 PM)</option>';tw.appendChild(tl);tw.appendChild(tfSel);tzp.parentNode.appendChild(tw);var storedTf;try{{storedTf=localStorage.getItem('sloc-tf')||'24';}}catch(e){{storedTf='24';}}tfSel.value=storedTf;tfSel.addEventListener('change',function(){{window.applyTf(this.value);}});}})();
12148 btn.addEventListener('click',function(e){{e.stopPropagation();var r=btn.getBoundingClientRect();m.style.top=(r.bottom+6)+'px';m.style.right=(window.innerWidth-r.right)+'px';m.classList.toggle('open');}});
12149 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
12150 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
12151 }})();
12152 }})();
12153
12154 var ROOTS = {roots_json};
12155 var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
12156 var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
12157 var allData = [];
12158
12159 // Populate root selector
12160 var rootSel = document.getElementById('root-sel');
12161 ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
12162
12163 function fmt(n){{var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}}
12164 function fmtFull(n){{return Number(n).toLocaleString();}}
12165 function esc(s){{ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }}
12166
12167 // Tooltip
12168 var tt = document.createElement('div');
12169 tt.style.cssText = 'display:none;position:fixed;pointer-events:none;background:var(--surface);border:1px solid var(--line-strong);border-radius:8px;padding:9px 13px;font-family:'+FONT+';font-size:12px;line-height:1.6;box-shadow:0 4px 18px rgba(0,0,0,0.15);z-index:100000;max-width:280px;color:var(--text);';
12170 document.body.appendChild(tt);
12171 function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
12172 function moveTT(e){{var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;tt.style.left=x+'px';tt.style.top=y+'px';}}
12173 function hideTT(){{tt.style.display='none';}}
12174 window.addEventListener('blur',function(){{hideTT();}});
12175 document.addEventListener('visibilitychange',function(){{if(document.hidden)hideTT();}});
12176
12177 function statExact(compact, full){{
12178 return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
12179 }}
12180 function statVal(n){{
12181 var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
12182 }}
12183
12184 function updateStats(data){{
12185 var statsEl=document.getElementById('trend-stats');
12186 if(!statsEl)return;
12187 if(!data||!data.length){{statsEl.innerHTML='';return;}}
12188 var yKey=document.getElementById('y-sel').value;
12189 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
12190 var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12191 var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
12192 var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
12193 var absDelta=Math.abs(delta);
12194 var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
12195 var deltaExact=statExact(deltaCompact,deltaFull);
12196 var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
12197 statsEl.innerHTML=
12198 '<div class="stat-chip"><div class="stat-chip-tip">Total scan runs recorded in this workspace</div><div class="stat-chip-val">'+data.length+'</div><div class="stat-chip-label">Total Scans</div></div>'+
12199 '<div class="stat-chip"><div class="stat-chip-tip">The most recent recorded value for the selected metric</div><div class="stat-chip-val">'+statVal(lastVal)+'</div><div class="stat-chip-label">Latest '+(Y_LABELS[yKey]||yKey)+'</div></div>'+
12200 '<div class="stat-chip"><div class="stat-chip-tip">Change in the selected metric from the earliest to the latest scan</div><div class="stat-chip-val '+cls+'">'+sign+deltaCompact+deltaExact+'</div><div class="stat-chip-label">Net Change</div></div>'+
12201 '<div class="stat-chip"><div class="stat-chip-tip">Number of distinct project roots tracked across all scans</div><div class="stat-chip-val">'+Object.keys(projs).length+'</div><div class="stat-chip-label">Projects</div></div>';
12202 }}
12203
12204 var subSel = document.getElementById('sub-sel');
12205 var subLabel = document.getElementById('submodule-label');
12206
12207 function populateSubmodules(root){{
12208 if(!subSel||!subLabel)return;
12209 while(subSel.options.length>1)subSel.remove(1);
12210 subSel.value='';
12211 var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
12212 fetch(url)
12213 .then(function(r){{return r.json();}})
12214 .then(function(subs){{
12215 if(!subs||!subs.length){{subLabel.style.display='none';return;}}
12216 subs.forEach(function(s){{
12217 var o=document.createElement('option');
12218 o.value=s.name;
12219 o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
12220 subSel.appendChild(o);
12221 }});
12222 subLabel.style.display='';
12223 }})
12224 .catch(function(){{subLabel.style.display='none';}});
12225 }}
12226
12227 var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div>';
12228
12229 function loadAndRender(){{
12230 var root = rootSel.value;
12231 var sub = subSel ? subSel.value : '';
12232 document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
12233 document.getElementById('data-table-wrap').innerHTML='';
12234 var url = '/api/metrics/history?limit=100'
12235 + (root ? '&root='+encodeURIComponent(root) : '')
12236 + (sub ? '&submodule='+encodeURIComponent(sub) : '');
12237 fetch(url).then(function(r){{return r.json();}}).then(function(data){{
12238 allData = data;
12239 render(data);
12240 updateStats(data);
12241 }}).catch(function(){{
12242 document.getElementById('chart-wrap').innerHTML='<div class="empty-state">Failed to load scan history. Make sure the server is running and has recorded at least one scan.</div>';
12243 }});
12244 }}
12245
12246 function render(data){{
12247 var yKey = document.getElementById('y-sel').value;
12248 var xMode = document.getElementById('x-sel').value;
12249
12250 // Filter for tag/release mode
12251 var pts = data;
12252 if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
12253
12254 // Sort oldest-first for the line chart
12255 pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12256
12257 var wrap = document.getElementById('chart-wrap');
12258 if(!pts.length){{
12259 var emptyMsg = (xMode === 'tag')
12260 ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
12261 : 'No scan data found for the selected filters.';
12262 wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
12263 renderTable([]);
12264 return;
12265 }}
12266
12267 var scaleEl=document.getElementById('scale-sel');
12268 var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
12269 renderTrendInto(wrap, pts, yKey, xMode, sc);
12270 renderTable(pts, yKey);
12271 }}
12272
12273 // Draw the trend area+line chart (with points and tooltips) into `wrap` at scale `sc`.
12274 // Shared by the inline chart and the Full View modal so both render identically.
12275 function renderTrendInto(wrap, pts, yKey, xMode, sc){{
12276 // Fill the container width (like the Chart.js charts) instead of a fixed 900px
12277 // canvas centered with empty margins; Chart Size (sc) drives height + detail.
12278 var availW=Math.round(wrap.clientWidth||wrap.offsetWidth||900*sc);
12279 var W=Math.max(600,availW),H=Math.round(380*sc),PL=Math.round(80*sc),PR=Math.round(40*sc),PT=Math.round(30*sc),PB=Math.round(60*sc),CW=W-PL-PR,CH=H-PT-PB;
12280 var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
12281
12282 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
12283
12284 var svg='<svg viewBox="0 0 '+W+' '+H+'" width="'+W+'" height="'+H+'" style="display:block;overflow:visible;max-width:100%;cursor:default;" xmlns="http://www.w3.org/2000/svg">';
12285 svg+='<defs><linearGradient id="areaFill" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#C45C10" stop-opacity="0.18"/><stop offset="100%" stop-color="#C45C10" stop-opacity="0"/></linearGradient></defs>';
12286
12287 var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
12288
12289 // Grid + Y axis ticks
12290 for(var ti=0;ti<=5;ti++){{
12291 var gy=PT+CH-Math.round(ti/5*CH);
12292 var gv=Math.round(ti/5*maxY);
12293 svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
12294 svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmtFull(gv)+'</text>';
12295 }}
12296
12297 // X axis labels (every N-th point to avoid crowding)
12298 var labelEvery=Math.max(1,Math.ceil(pts.length/10));
12299 pts.forEach(function(d,i){{
12300 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
12301 if(i%labelEvery===0||i===pts.length-1){{
12302 var lbl=xMode==='commit'&&d.commit?d.commit.substring(0,7):(xMode==='release'?(d.nearest_tag||d.tags&&d.tags[0]||d.timestamp.substring(0,10)):(d.tags&&d.tags[0]?d.tags[0]:d.timestamp.substring(0,10)));
12303 svg+='<text x="'+x+'" y="'+(PT+CH+fsS*2)+'" text-anchor="middle" transform="rotate(30,'+x+','+(PT+CH+fsS*2)+')" font-family="'+FONT+'" font-size="'+fsS+'" fill="#7b675b">'+esc(lbl)+'</text>';
12304 }}
12305 }});
12306
12307 // Axis label
12308 var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
12309 svg+='<text x="'+(PL+CW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+fsL+'" font-weight="700" fill="#7b675b">'+xAxisLabel+'</text>';
12310 svg+='<text x="'+Math.round(14*sc)+'" y="'+(PT+CH/2)+'" text-anchor="middle" transform="rotate(-90,'+Math.round(14*sc)+','+(PT+CH/2)+')" font-family="'+FONT+'" font-size="'+fsL+'" font-weight="700" fill="#7b675b">'+(Y_LABELS[yKey]||yKey)+'</text>';
12311
12312 // Area fill + line path
12313 var pathD='';
12314 pts.forEach(function(d,i){{
12315 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
12316 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
12317 pathD+=(i===0?'M':'L')+x+','+y;
12318 }});
12319 if(pts.length>1){{
12320 var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
12321 svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
12322 }}
12323 svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
12324
12325 // Data points (clickable) + permanent value labels
12326 var showLabels = pts.length <= 40;
12327 var labelEveryN = pts.length > 20 ? 2 : 1;
12328 pts.forEach(function(d,i){{
12329 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
12330 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
12331 var hasTags=d.tags&&d.tags.length>0;
12332 var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
12333 var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
12334 svg+='<circle class="trend-pt" cx="'+x+'" cy="'+y+'" r="'+r+'" fill="'+(isReleasePoint?'#4472C4':'#C45C10')+'" stroke="white" stroke-width="2" style="cursor:pointer;" data-idx="'+i+'"/>';
12335 if(showLabels && i%labelEveryN===0){{
12336 var lx=x, ly=y-r-5;
12337 svg+='<text x="'+lx+'" y="'+ly+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+fs+'" font-weight="700" fill="#7b675b" pointer-events="none">'+fmtFull(Number(d[yKey]))+'</text>';
12338 }}
12339 }});
12340
12341 svg+='</svg>';
12342 wrap.innerHTML=svg;
12343
12344 // Pixel Y of the line at chart-space x (straight segments → linear interpolation).
12345 function lineYAt(mx){{
12346 var n=pts.length;
12347 if(n===0)return PT+CH;
12348 if(n===1)return PT+CH-Math.round((Number(pts[0][yKey])||0)/maxY*CH);
12349 var fx=(mx-PL)/Math.max(CW,1)*(n-1);
12350 if(fx<0)fx=0; if(fx>n-1)fx=n-1;
12351 var i0=Math.floor(fx),i1=Math.min(i0+1,n-1),t=fx-i0;
12352 var y0=PT+CH-(Number(pts[i0][yKey])||0)/maxY*CH;
12353 var y1=PT+CH-(Number(pts[i1][yKey])||0)/maxY*CH;
12354 return y0+t*(y1-y0);
12355 }}
12356
12357 // SVG-level mousemove: show the value tooltip only when the pointer is over the
12358 // gradient fill (inside the chart and at/below the line) — never in the empty
12359 // space above the line. Cursor follows the same rule.
12360 (function(){{
12361 var svgEl=wrap.querySelector('svg');
12362 if(!svgEl)return;
12363 svgEl.addEventListener('mousemove',function(e){{
12364 if(e.target&&e.target.classList&&e.target.classList.contains('trend-pt'))return; // circle handles its own tooltip
12365 var rect=svgEl.getBoundingClientRect();
12366 var scaleX=W/Math.max(rect.width,1);
12367 var scaleY=H/Math.max(rect.height,1);
12368 var mouseX=(e.clientX-rect.left)*scaleX;
12369 var mouseY=(e.clientY-rect.top)*scaleY;
12370 var ly=lineYAt(mouseX);
12371 if(mouseX<PL||mouseX>PL+CW||mouseY<ly-6*sc||mouseY>PT+CH){{hideTT();svgEl.style.cursor='default';return;}}
12372 svgEl.style.cursor='pointer';
12373 var idx=Math.max(0,Math.min(pts.length-1,Math.round((mouseX-PL)/Math.max(CW,1)*(pts.length-1))));
12374 var d=pts[idx];
12375 var val=Number(d[yKey]);
12376 var lbl=xMode==='commit'&&d.commit?d.commit.substring(0,7):d.timestamp.substring(0,10);
12377 showTT(e,
12378 '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(lbl)+'</strong>'+
12379 (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(val)+'</strong>'+
12380 '<br><span style="font-size:11px;color:var(--muted);">'+d.timestamp.substring(0,10)+'</span>'
12381 );
12382 }});
12383 svgEl.addEventListener('mouseleave',function(){{hideTT();svgEl.style.cursor='default';}});
12384 }})();
12385
12386 // Attach point tooltips
12387 wrap.querySelectorAll('.trend-pt').forEach(function(c){{
12388 c.addEventListener('mouseover',function(e){{
12389 var d=pts[parseInt(this.dataset.idx)];
12390 var tagsHtml=d.tags&&d.tags.length?'<br>Tags: '+d.tags.map(function(t){{return'<span style="background:var(--info-bg);color:var(--info-text);padding:1px 6px;border-radius:999px;font-size:10px;margin-right:3px;">'+esc(t)+'</span>';}}).join(''):'';
12391 var nearestHtml=d.nearest_tag?'<br>Nearest release: <span style="background:var(--info-bg);color:var(--info-text);padding:1px 6px;border-radius:999px;font-size:10px;">'+esc(d.nearest_tag)+'</span>':'';
12392 showTT(e,
12393 '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
12394 (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
12395 'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
12396 (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
12397 );
12398 this.setAttribute('r','8');
12399 }});
12400 c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
12401 c.addEventListener('mousemove',moveTT);
12402 c.addEventListener('click',function(){{
12403 var d=pts[parseInt(this.dataset.idx)];
12404 if(d.html_url) window.open(d.html_url,'_blank');
12405 }});
12406 }});
12407 }}
12408
12409 var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
12410 var shProjFilter='', shBranchFilter='';
12411
12412 function fmtPST(isoStr){{
12413 if(!isoStr)return'';
12414 var d=new Date(isoStr);
12415 if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
12416 if(window.fmtTz){{var tz;try{{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{tz='America/Los_Angeles';}}return window.fmtTz(d.getTime(),tz);}}
12417 function p(n){{return n<10?'0'+n:String(n);}}
12418 function nthWeekdaySun(year,month,n){{var count=0,day=1;while(true){{var t=new Date(Date.UTC(year,month,day));if(t.getUTCDay()===0&&++count===n)return t;day++;}}}}
12419 var yr=d.getUTCFullYear();
12420 var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
12421 var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
12422 var isDST=d>=dstStart&&d<dstEnd;
12423 var off=isDST?-7*3600*1000:-8*3600*1000;
12424 var lbl=isDST?'PDT':'PST';
12425 var loc=new Date(d.getTime()+off);
12426 return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
12427 }}
12428
12429 function getShRows(){{
12430 var proj=shProjFilter.toLowerCase().trim();
12431 var branch=shBranchFilter;
12432 return shData.filter(function(d){{
12433 if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
12434 if(branch&&(d.branch||'')!==branch)return false;
12435 return true;
12436 }});
12437 }}
12438
12439 function renderShPage(){{
12440 var filtered=getShRows();
12441 if(shSortCol){{
12442 filtered.sort(function(a,b){{
12443 var va,vb;
12444 if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
12445 if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
12446 else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
12447 else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
12448 else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
12449 return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
12450 }});
12451 }}
12452 var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
12453 shPage=Math.min(shPage,totalPages);
12454 var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
12455 var visible=filtered.slice(start,end);
12456 var tbody=document.getElementById('sh-tbody');
12457 if(!tbody)return;
12458 tbody.innerHTML=visible.map(function(d){{
12459 var tsHtml=esc(fmtPST(d.timestamp));
12460 var tags=(d.tags&&d.tags.length)?d.tags.map(function(t){{return'<span class="tag-chip">'+esc(t)+'</span>';}}).join(''):'<span style="color:var(--muted)">—</span>';
12461 var commitHtml=d.commit?'<span class="git-chip" title="'+esc(d.commit)+'">'+esc(d.commit.substring(0,7))+'</span>':'<span style="color:var(--muted)">—</span>';
12462 var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">—</span>';
12463 var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'—';
12464 var metricHtml='<span class="metric-num">'+fmtFull(d._metricVal)+'</span>';
12465 var reportCell='';
12466 if(d.html_url){{
12467 reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
12468 if(d.has_pdf){{var pdfUrl=d.html_url.replace(/\/html$/,'/pdf');reportCell+='<a class="btn primary rpt-btn" href="'+esc(pdfUrl)+'" target="_blank" rel="noopener">PDF</a>';}}
12469 reportCell+='</div>';
12470 }}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">—</span>';}}
12471 if(d.submodule_links&&d.submodule_links.length){{
12472 reportCell+='<details class="submod-details"><summary>↳ '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
12473 d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
12474 reportCell+='</div></details>';
12475 }}
12476 return '<tr>'
12477 +'<td>'+tsHtml+'</td>'
12478 +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
12479 +'<td>'+runIdHtml+'</td>'
12480 +'<td>'+commitHtml+'</td>'
12481 +'<td>'+branchHtml+'</td>'
12482 +'<td>'+tags+'</td>'
12483 +'<td class="num">'+metricHtml+'</td>'
12484 +'<td class="report-cell">'+reportCell+'</td>'
12485 +'</tr>';
12486 }}).join('');
12487 var pgRange=document.getElementById('sh-pg-range');
12488 if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'\u2013'+end+' of '+total:'No results';
12489 var pgInfo=document.getElementById('sh-pg-info');
12490 if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
12491 var pgBtns=document.getElementById('sh-pg-btns');
12492 if(pgBtns){{
12493 pgBtns.innerHTML='';
12494 function mkPgBtn(lbl,pg,active,disabled){{
12495 var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
12496 if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
12497 return b;
12498 }}
12499 pgBtns.appendChild(mkPgBtn('\u2039',shPage-1,false,shPage===1));
12500 var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
12501 for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
12502 pgBtns.appendChild(mkPgBtn('\u203a',shPage+1,false,shPage===totalPages));
12503 }}
12504 }}
12505
12506 function wireTableBehavior(){{
12507 var pf=document.getElementById('sh-proj-filter');
12508 if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
12509 var bf=document.getElementById('sh-branch-filter');
12510 if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
12511 var rb=document.getElementById('sh-reset-btn');
12512 if(rb)rb.addEventListener('click',function(){{
12513 shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
12514 var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
12515 var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
12516 document.querySelectorAll('#sh-thead .sortable').forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
12517 renderShPage();
12518 }});
12519 var pps=document.getElementById('sh-per-page');
12520 if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
12521 var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
12522 ths.forEach(function(th){{
12523 th.addEventListener('click',function(e){{
12524 if(e.target.classList.contains('col-resize-handle'))return;
12525 var col=th.dataset.col;
12526 if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
12527 ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
12528 th.classList.add('sort-'+shSortOrder);
12529 var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'\u2191':'\u2193';
12530 shPage=1;renderShPage();
12531 }});
12532 }});
12533 var table=document.getElementById('scan-history-table');
12534 if(!table)return;
12535 var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
12536 var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
12537 allThs.forEach(function(th,i){{
12538 var handle=th.querySelector('.col-resize-handle');
12539 if(!handle||!cols[i])return;
12540 var startX,startW;
12541 handle.addEventListener('mousedown',function(e){{
12542 e.stopPropagation();e.preventDefault();
12543 startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
12544 handle.classList.add('dragging');
12545 function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
12546 function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
12547 document.addEventListener('mousemove',onMove);
12548 document.addEventListener('mouseup',onUp);
12549 }});
12550 }});
12551 }}
12552
12553 function renderTable(pts, yKey){{
12554 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
12555 var wrap=document.getElementById('data-table-wrap');
12556 if(!pts||!pts.length){{wrap.innerHTML='';return;}}
12557 var yLabel=Y_LABELS[yKey]||yKey||'';
12558 shData=pts.slice().reverse();
12559 shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
12560 shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
12561 var branches={{}};
12562 shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
12563 var branchOpts='<option value="">All branches</option>';
12564 Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
12565 wrap.innerHTML=
12566 '<div class="chart-section-header">SCAN HISTORY</div>'+
12567 '<div class="filter-row">'+
12568 '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by path or name\u2026">'+
12569 '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
12570 '<button type="button" class="btn" id="sh-reset-btn">\u21bb Reset view</button>'+
12571 '</div>'+
12572 '<div class="table-wrap">'+
12573 '<table id="scan-history-table" class="data-table">'+
12574 '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
12575 '<thead><tr id="sh-thead">'+
12576 '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
12577 '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
12578 '<th>Run ID<div class="col-resize-handle"></div></th>'+
12579 '<th>Commit<div class="col-resize-handle"></div></th>'+
12580 '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
12581 '<th>Tags<div class="col-resize-handle"></div></th>'+
12582 '<th class="sortable num" data-col="metric" data-type="num">'+esc(yLabel)+'<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
12583 '<th>Report<div class="col-resize-handle"></div></th>'+
12584 '</tr></thead>'+
12585 '<tbody id="sh-tbody"></tbody>'+
12586 '</table>'+
12587 '</div>'+
12588 '<div class="pagination">'+
12589 '<span class="pagination-info" id="sh-pg-info"></span>'+
12590 '<div class="pagination-btns" id="sh-pg-btns"></div>'+
12591 '<div style="display:flex;align-items:center;gap:8px;">'+
12592 '<span style="font-size:13px;color:var(--muted);">Show</span>'+
12593 '<select class="filter-select" id="sh-per-page">'+
12594 '<option value="10">10 per page</option>'+
12595 '<option value="25" selected>25 per page</option>'+
12596 '<option value="50">50 per page</option>'+
12597 '<option value="100">100 per page</option>'+
12598 '</select>'+
12599 '<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
12600 '</div>'+
12601 '</div>';
12602 wireTableBehavior();
12603 renderShPage();
12604 }}
12605
12606 function exportXLSX(){{
12607 if(!allData||!allData.length){{alert('No data to export yet.');return;}}
12608 var xbtn=document.getElementById('export-xlsx-btn');
12609 var xorig=xbtn?xbtn.innerHTML:'';
12610 if(xbtn){{xbtn.disabled=true;xbtn.textContent='Preparing\u2026';}}
12611 var root=rootSel.value;
12612 var url='/api/metrics/churn?limit=500'+(root?'&root='+encodeURIComponent(root):'');
12613 fetch(url).then(function(r){{return r.ok?r.json():[];}}).catch(function(){{return [];}}).then(function(churn){{
12614 var cm={{}};(churn||[]).forEach(function(c){{cm[c.run_id]=c;}});
12615 buildAndDownloadXLSX(cm);
12616 }}).finally(function(){{if(xbtn){{xbtn.disabled=false;xbtn.innerHTML=xorig;}}}});
12617 }}
12618
12619 function buildAndDownloadXLSX(churnMap){{
12620 var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
12621 // X-axis is the git commit. Dedupe by project+commit, keeping the latest scan
12622 // (sorted is newest-first), so a given project/commit appears at most once.
12623 var seenPC={{}},dedup=[];
12624 sorted.forEach(function(d){{var k=(d.project_label||'')+'|'+(d.commit||'');if(!seenPC[k]){{seenPC[k]=1;dedup.push(d);}}}});
12625 var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL','Added','Deleted','Modified','Unmodified'];
12626 var s1R=dedup.map(function(d){{
12627 var c=churnMap[d.run_id]||{{}};
12628 return[d.timestamp.substring(0,16).replace('T',' '),d.project_label||'',(d.commit||'').substring(0,7),d.branch||'',(d.tags||[]).join('; '),+(d.code_lines)||0,+(d.comment_lines)||0,+(d.blank_lines)||0,+(d.physical_lines)||0,+(d.files_analyzed)||0,d.html_url||'',+(c.added)||0,+(c.removed)||0,+(c.modified)||0,+(c.unmodified)||0];
12629 }});
12630 var pm={{}};
12631 dedup.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
12632 var s2H=['Project','Scan Count','First Scan','Latest Scan','Latest Code Lines','Latest Comment Lines','Latest Blank Lines','Latest Physical Lines','Latest Files','Min Code Lines','Max Code Lines','Avg Code Lines'];
12633 var s2R=Object.keys(pm).map(function(p){{
12634 var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12635 var lat=sc[sc.length-1],fst=sc[0];
12636 var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
12637 var mn=Math.min.apply(null,codes),mx=Math.max.apply(null,codes),av=Math.round(codes.reduce(function(a,b){{return a+b;}},0)/codes.length);
12638 return[p,sc.length,fst.timestamp.substring(0,16).replace('T',' '),lat.timestamp.substring(0,16).replace('T',' '),+(lat.code_lines)||0,+(lat.comment_lines)||0,+(lat.blank_lines)||0,+(lat.physical_lines)||0,+(lat.files_analyzed)||0,mn,mx,av];
12639 }});
12640 var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}},{{name:'Focus Chart',headers:[],rows:[]}}],s1R,s2R);
12641 var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
12642 a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
12643 a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
12644 }}
12645
12646 function buildXLSX(sheets,chartRows,chartRows2){{
12647 function s2b(s){{return new TextEncoder().encode(s);}}
12648 function xe(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}}
12649 function col2l(n){{var s='';while(n>0){{var r=(n-1)%26;s=String.fromCharCode(65+r)+s;n=Math.floor((n-1)/26);}}return s;}}
12650 function crc32(d){{
12651 if(!crc32.t){{crc32.t=new Uint32Array(256);for(var i=0;i<256;i++){{var c=i;for(var j=0;j<8;j++)c=(c&1)?(0xEDB88320^(c>>>1)):(c>>>1);crc32.t[i]=c;}}}}
12652 var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
12653 }}
12654 function buildSheet(hdr,rows,drawRid,withCtrl){{
12655 var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
12656 if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
12657 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
12658 x+='<row r="1">';
12659 hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
12660 if(withCtrl){{x+='<c r="Q1" t="inlineStr" s="1"><is><t>Selected Metric (set on Focus Chart tab)</t></is></c>';}}
12661 x+='</row>';
12662 rows.forEach(function(row,ri){{
12663 var rn=ri+2;
12664 x+='<row r="'+rn+'">';
12665 row.forEach(function(cell,ci){{
12666 var addr=col2l(ci+1)+rn;
12667 if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
12668 else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
12669 }});
12670 if(withCtrl){{x+="<c r=\"Q"+rn+"\"><f>CHOOSE(MATCH('Focus Chart'!$B$1,{{\"Code Lines\",\"Comment Lines\",\"Blank Lines\",\"Physical Lines\",\"Added\",\"Deleted\",\"Modified\",\"Unmodified\"}},0),F"+rn+",G"+rn+",H"+rn+",I"+rn+",L"+rn+",M"+rn+",N"+rn+",O"+rn+")</f><v>"+Number(row[5])+"</v></c>";}}
12671 x+='</row>';
12672 }});
12673 x+='</sheetData>';
12674 if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
12675 return x+'</worksheet>';
12676 }}
12677 function buildChartXML(rows){{
12678 var sn="'Scan History'";
12679 var nr=rows.length,er=nr+1;
12680 var sd=[{{name:'Code Lines',col:'F',di:5,clr:'C45C10'}},{{name:'Comment Lines',col:'G',di:6,clr:'4472C4'}},{{name:'Blank Lines',col:'H',di:7,clr:'70AD47'}},{{name:'Physical Lines',col:'I',di:8,clr:'7030A0'}}];
12681 var catCol='C',catIdx=2;
12682 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12683 x+='<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">';
12684 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart>';
12685 x+='<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:pPr><a:defRPr sz="1400" b="1"/></a:pPr><a:r><a:rPr lang="en-US" sz="1400" b="1"/><a:t>Scan History \u2014 all metrics over time</a:t></a:r></a:p></c:rich></c:tx><c:overlay val="0"/></c:title><c:autoTitleDeleted val="0"/><c:plotArea>';
12686 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12687 sd.forEach(function(s,i){{
12688 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
12689 x+='<c:tx><c:strRef><c:f>'+sn+'!$'+s.col+'$1</c:f><c:strCache><c:ptCount val="1"/><c:pt idx="0"><c:v>'+xe(s.name)+'</c:v></c:pt></c:strCache></c:strRef></c:tx>';
12690 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
12691 x+='<c:marker><c:symbol val="circle"/><c:size val="4"/><c:spPr><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill><a:ln><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr></c:marker>';
12692 x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12693 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12694 x+='</c:strCache></c:strRef></c:cat>';
12695 x+='<c:val><c:numRef><c:f>'+sn+'!$'+s.col+'$2:$'+s.col+'$'+er+'</c:f><c:numCache><c:formatCode>General</c:formatCode><c:ptCount val="'+nr+'"/>';
12696 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
12697 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12698 }});
12699 x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
12700 x+='<c:catAx><c:axId val="1"/><c:scaling><c:orientation val="minMax"/></c:scaling><c:delete val="0"/><c:axPos val="b"/><c:tickLblPos val="nextTo"/><c:crossAx val="2"/></c:catAx>';
12701 x+='<c:valAx><c:axId val="2"/><c:scaling><c:orientation val="minMax"/></c:scaling><c:delete val="0"/><c:axPos val="l"/><c:tickLblPos val="nextTo"/><c:crossAx val="1"/><c:crossBetween val="between"/></c:valAx>';
12702 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
12703 return x;
12704 }}
12705 function buildChartXML2(rows){{
12706 var sn="'By Project'";
12707 var nr=rows.length,er=nr+1;
12708 var sd=[{{name:'Latest Code Lines',col:'E',di:4,clr:'C45C10'}},{{name:'Latest Comment Lines',col:'F',di:5,clr:'4472C4'}},{{name:'Latest Blank Lines',col:'G',di:6,clr:'70AD47'}},{{name:'Latest Physical Lines',col:'H',di:7,clr:'7030A0'}}];
12709 var catCol='A',catIdx=0;
12710 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12711 x+='<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">';
12712 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart>';
12713 x+='<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:pPr><a:defRPr sz="1400" b="1"/></a:pPr><a:r><a:rPr lang="en-US" sz="1400" b="1"/><a:t>Latest metrics by project</a:t></a:r></a:p></c:rich></c:tx><c:overlay val="0"/></c:title><c:autoTitleDeleted val="0"/><c:plotArea>';
12714 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12715 sd.forEach(function(s,i){{
12716 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
12717 x+='<c:tx><c:strRef><c:f>'+sn+'!$'+s.col+'$1</c:f><c:strCache><c:ptCount val="1"/><c:pt idx="0"><c:v>'+xe(s.name)+'</c:v></c:pt></c:strCache></c:strRef></c:tx>';
12718 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
12719 x+='<c:marker><c:symbol val="circle"/><c:size val="4"/><c:spPr><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill><a:ln><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr></c:marker>';
12720 x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12721 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12722 x+='</c:strCache></c:strRef></c:cat>';
12723 x+='<c:val><c:numRef><c:f>'+sn+'!$'+s.col+'$2:$'+s.col+'$'+er+'</c:f><c:numCache><c:formatCode>General</c:formatCode><c:ptCount val="'+nr+'"/>';
12724 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
12725 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12726 }});
12727 x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
12728 x+='<c:catAx><c:axId val="3"/><c:scaling><c:orientation val="minMax"/></c:scaling><c:delete val="0"/><c:axPos val="b"/><c:tickLblPos val="nextTo"/><c:crossAx val="4"/></c:catAx>';
12729 x+='<c:valAx><c:axId val="4"/><c:scaling><c:orientation val="minMax"/></c:scaling><c:delete val="0"/><c:axPos val="l"/><c:tickLblPos val="nextTo"/><c:crossAx val="3"/><c:crossBetween val="between"/></c:valAx>';
12730 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
12731 return x;
12732 }}
12733 function buildChartXML3(rows){{
12734 var sn="'Scan History'";
12735 var nr=rows.length,er=nr+1;
12736 var catCol='C',catIdx=2;
12737 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12738 x+='<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">';
12739 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
12740 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12741 x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
12742 x+="<c:tx><c:strRef><c:f>'Focus Chart'!$B$1</c:f><c:strCache><c:ptCount val=\"1\"/><c:pt idx=\"0\"><c:v>Code Lines</c:v></c:pt></c:strCache></c:strRef></c:tx>";
12743 x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
12744 x+='<c:marker><c:symbol val="circle"/><c:size val="6"/><c:spPr><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill><a:ln><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr></c:marker>';
12745 x+='<c:dLbls><c:numFmt formatCode="General" sourceLinked="0"/><c:spPr/><c:showLegendKey val="0"/><c:showVal val="1"/><c:showCatName val="0"/><c:showSerName val="0"/><c:showPercent val="0"/><c:showBubbleSize val="0"/><c:dLblPos val="t"/></c:dLbls>';
12746 x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12747 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12748 x+='</c:strCache></c:strRef></c:cat>';
12749 x+='<c:val><c:numRef><c:f>'+sn+'!$Q$2:$Q$'+er+'</c:f><c:numCache><c:formatCode>General</c:formatCode><c:ptCount val="'+nr+'"/>';
12750 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
12751 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12752 x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
12753 x+='<c:catAx><c:axId val="5"/><c:scaling><c:orientation val="minMax"/></c:scaling><c:delete val="0"/><c:axPos val="b"/><c:tickLblPos val="nextTo"/><c:crossAx val="6"/></c:catAx>';
12754 x+='<c:valAx><c:axId val="6"/><c:scaling><c:orientation val="minMax"/></c:scaling><c:delete val="0"/><c:axPos val="l"/><c:tickLblPos val="nextTo"/><c:crossAx val="5"/><c:crossBetween val="between"/></c:valAx>';
12755 x+='</c:plotArea><c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:pPr><a:defRPr sz="1400" b="1"/></a:pPr><a:r><a:rPr lang="en-US" sz="1400" b="1"/><a:t>Single-Metric Focus</a:t></a:r></a:p></c:rich></c:tx><c:overlay val="0"/></c:title><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
12756 return x;
12757 }}
12758 function buildFocusSheet(drawRid){{
12759 var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
12760 if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
12761 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>';
12762 x+='<cols><col min="1" max="1" width="11" customWidth="1"/><col min="2" max="2" width="20" customWidth="1"/></cols>';
12763 x+='<sheetData><row r="1">';
12764 x+='<c r="A1" t="inlineStr" s="1"><is><t>Metric:</t></is></c>';
12765 x+='<c r="B1" t="inlineStr"><is><t>Code Lines</t></is></c>';
12766 x+='<c r="D1" t="inlineStr"><is><t>← Pick a metric from the dropdown to update the chart below</t></is></c>';
12767 x+='</row></sheetData>';
12768 x+='<dataValidations count="1"><dataValidation type="list" allowBlank="1" showDropDown="0" showInputMessage="1" showErrorAlert="1" sqref="B1"><formula1>"Code Lines,Comment Lines,Blank Lines,Physical Lines,Added,Deleted,Modified,Unmodified"</formula1></dataValidation></dataValidations>';
12769 if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
12770 return x+'</worksheet>';
12771 }}
12772 var hasChart=!!(chartRows&&chartRows.length);
12773 var nr=hasChart?chartRows.length:0;
12774 var hasChart2=!!(chartRows2&&chartRows2.length);
12775 var nr2=hasChart2?chartRows2.length:0;
12776 var styl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><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"/></cellXfs></styleSheet>';
12777 var ct='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/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"/>';
12778 sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
12779 if(hasChart){{ct+='<Override PartName="/xl/charts/chart1.xml" ContentType="application/vnd.openxmlformats-officedocument.drawingml.chart+xml"/><Override PartName="/xl/charts/chart3.xml" ContentType="application/vnd.openxmlformats-officedocument.drawingml.chart+xml"/><Override PartName="/xl/drawings/drawing1.xml" ContentType="application/vnd.openxmlformats-officedocument.drawing+xml"/><Override PartName="/xl/drawings/drawing3.xml" ContentType="application/vnd.openxmlformats-officedocument.drawing+xml"/>';}}
12780 if(hasChart2){{ct+='<Override PartName="/xl/charts/chart2.xml" ContentType="application/vnd.openxmlformats-officedocument.drawingml.chart+xml"/><Override PartName="/xl/drawings/drawing2.xml" ContentType="application/vnd.openxmlformats-officedocument.drawing+xml"/>';}}
12781 ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
12782 var dotrels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>';
12783 var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
12784 sheets.forEach(function(s,i){{wbr+='<Relationship Id="rId'+(i+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet'+(i+1)+'.xml"/>';}});
12785 wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
12786 var wbx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>';
12787 sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
12788 wbx+='</sheets></workbook>';
12789 var files=[
12790 {{name:'[Content_Types].xml',data:s2b(ct)}},
12791 {{name:'_rels/.rels',data:s2b(dotrels)}},
12792 {{name:'xl/workbook.xml',data:s2b(wbx)}},
12793 {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
12794 {{name:'xl/styles.xml',data:s2b(styl)}}
12795 ];
12796 // Chart embedded directly in Scan History (sheet1); By Project is plain
12797 sheets.forEach(function(s,i){{
12798 var sx;
12799 if(s.name==='Focus Chart'){{sx=buildFocusSheet(hasChart?'rId1':null);}}
12800 else{{sx=buildSheet(s.headers,s.rows,(hasChart&&i===0)?'rId1':(hasChart2&&i===1)?'rId1':null,(hasChart&&i===0));}}
12801 files.push({{name:'xl/worksheets/sheet'+(i+1)+'.xml',data:s2b(sx)}});
12802 }});
12803 if(hasChart){{
12804 var fromRow=nr+4,toRow=nr+34;
12805 files.push({{name:'xl/worksheets/_rels/sheet1.xml.rels',data:s2b('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing1.xml"/></Relationships>')}});
12806 var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12807 drx+='<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12808 drx+='<xdr:twoCellAnchor editAs="twoCell">';
12809 drx+='<xdr:from><xdr:col>0</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+fromRow+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from>';
12810 drx+='<xdr:to><xdr:col>17</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+toRow+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>';
12811 drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12812 drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12813 drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12814 drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12815 drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
12816 files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
12817 files.push({{name:'xl/drawings/_rels/drawing1.xml.rels',data:s2b('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart" Target="../charts/chart1.xml"/></Relationships>')}});
12818 files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
12819 files.push({{name:'xl/worksheets/_rels/sheet3.xml.rels',data:s2b('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing3.xml"/></Relationships>')}});
12820 var drx3='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12821 drx3+='<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12822 drx3+='<xdr:twoCellAnchor editAs="twoCell">';
12823 drx3+='<xdr:from><xdr:col>0</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>2</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from>';
12824 drx3+='<xdr:to><xdr:col>15</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>31</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>';
12825 drx3+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12826 drx3+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12827 drx3+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12828 drx3+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12829 drx3+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
12830 files.push({{name:'xl/drawings/drawing3.xml',data:s2b(drx3)}});
12831 files.push({{name:'xl/drawings/_rels/drawing3.xml.rels',data:s2b('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart" Target="../charts/chart3.xml"/></Relationships>')}});
12832 files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
12833 }}
12834 if(hasChart2){{
12835 var fromRow2=nr2+4,toRow2=nr2+36;
12836 files.push({{name:'xl/worksheets/_rels/sheet2.xml.rels',data:s2b('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing2.xml"/></Relationships>')}});
12837 var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12838 drx2+='<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12839 drx2+='<xdr:twoCellAnchor editAs="twoCell">';
12840 drx2+='<xdr:from><xdr:col>0</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+fromRow2+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:from>';
12841 drx2+='<xdr:to><xdr:col>17</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>'+toRow2+'</xdr:row><xdr:rowOff>0</xdr:rowOff></xdr:to>';
12842 drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12843 drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12844 drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12845 drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12846 drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
12847 files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
12848 files.push({{name:'xl/drawings/_rels/drawing2.xml.rels',data:s2b('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart" Target="../charts/chart2.xml"/></Relationships>')}});
12849 files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
12850 }}
12851 var parts=[],offsets=[],total=0;
12852 files.forEach(function(f){{
12853 offsets.push(total);
12854 var nb=s2b(f.name),crc=crc32(f.data);
12855 var h=new DataView(new ArrayBuffer(30+nb.length));
12856 h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
12857 h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
12858 h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
12859 h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
12860 for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
12861 parts.push(new Uint8Array(h.buffer));parts.push(f.data);
12862 total+=30+nb.length+f.data.length;
12863 }});
12864 var cdStart=total;
12865 files.forEach(function(f,fi){{
12866 var nb=s2b(f.name),crc=crc32(f.data);
12867 var cd=new DataView(new ArrayBuffer(46+nb.length));
12868 cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
12869 cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
12870 cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
12871 cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
12872 cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
12873 for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
12874 parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
12875 }});
12876 var cdSz=total-cdStart;
12877 var eocd=new DataView(new ArrayBuffer(22));
12878 eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
12879 eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
12880 eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
12881 parts.push(new Uint8Array(eocd.buffer));
12882 var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
12883 var out=new Uint8Array(sz);var off=0;
12884 parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
12885 return out.buffer;
12886 }}
12887
12888 function trendTitleParts(){{
12889 var ySel=document.getElementById('y-sel'),xSel=document.getElementById('x-sel');
12890 var subSelEl=document.getElementById('sub-sel');
12891 var metricLbl=ySel?ySel.options[ySel.selectedIndex].text:'Metric';
12892 var xLbl=xSel?xSel.options[xSel.selectedIndex].text:'';
12893 var proj=(document.getElementById('root-sel').value)||'All projects';
12894 var subTxt=(subSelEl&&subSelEl.value)?(' / '+subSelEl.value):'';
12895 var cnt=(allData&&allData.length)||0;
12896 var now=new Date();
12897 function p2(n){{return(n<10?'0':'')+n;}}
12898 var dstr=now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate())+' '+p2(now.getHours())+':'+p2(now.getMinutes());
12899 return{{title:metricLbl+' \u2014 '+xLbl,sub:'Project: '+proj+subTxt+' \u00b7 '+cnt+' scan'+(cnt===1?'':'s')+' \u00b7 Generated '+dstr,date:dstr}};
12900 }}
12901
12902 function exportPNG(){{
12903 var svgEl=document.querySelector('#chart-wrap svg');
12904 if(!svgEl){{alert('No chart to export yet.');return;}}
12905 var svgStr=new XMLSerializer().serializeToString(svgEl);
12906 var vb=svgEl.viewBox.baseVal,scale=2;
12907 var headerH=84,footerH=36;
12908 var lw=(vb.width||900),lh=(vb.height||380);
12909 var w=lw*scale,h=(lh+headerH+footerH)*scale;
12910 var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
12911 var url=URL.createObjectURL(blob);
12912 var img=new Image();
12913 var tp=trendTitleParts();
12914 img.onload=function(){{
12915 var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
12916 var ctx=canvas.getContext('2d');
12917 var cs=getComputedStyle(document.body);
12918 var bg=cs.getPropertyValue('--bg').trim()||'#f5efe8';
12919 var oxide=cs.getPropertyValue('--oxide').trim()||'#C45C10';
12920 var muted=cs.getPropertyValue('--muted').trim()||'#7b675b';
12921 ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
12922 ctx.scale(scale,scale);
12923 ctx.textBaseline='alphabetic';ctx.textAlign='left';
12924 ctx.fillStyle=oxide;ctx.font='800 23px '+FONT;ctx.fillText(tp.title,24,40);
12925 ctx.fillStyle=muted;ctx.font='600 13px '+FONT;ctx.fillText(tp.sub,24,62);
12926 ctx.fillStyle=muted;ctx.font='700 12px '+FONT;ctx.textAlign='right';ctx.fillText('OxideSLOC Trend Report',lw-24,40);ctx.textAlign='left';
12927 ctx.strokeStyle=oxide;ctx.globalAlpha=0.55;ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(24,74);ctx.lineTo(lw-24,74);ctx.stroke();ctx.globalAlpha=1;
12928 ctx.drawImage(img,0,headerH);
12929 var fy=headerH+lh;
12930 ctx.strokeStyle=oxide;ctx.globalAlpha=0.4;ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(24,fy+9);ctx.lineTo(lw-24,fy+9);ctx.stroke();ctx.globalAlpha=1;
12931 ctx.fillStyle=muted;ctx.font='600 11px '+FONT;ctx.textAlign='center';
12932 ctx.fillText('\u00a9 2026 OxideSLOC \u00b7 oxide-sloc v{version} \u00b7 AGPL-3.0-or-later \u00b7 github.com/oxide-sloc/oxide-sloc',lw/2,fy+27);
12933 ctx.textAlign='left';
12934 URL.revokeObjectURL(url);
12935 var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
12936 }};
12937 img.src=url;
12938 }}
12939
12940 function exportPDF(){{
12941 var svgEl=document.querySelector('#chart-wrap svg');
12942 if(!svgEl){{alert('No chart to export yet.');return;}}
12943 var tp=trendTitleParts();
12944 var svgStr=new XMLSerializer().serializeToString(svgEl);
12945 var statsEl=document.getElementById('trend-stats');
12946 var statsHtml=statsEl?statsEl.innerHTML:'';
12947 var yK=document.getElementById('y-sel').value;
12948 var yLabels={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
12949 var yL=yLabels[yK]||yK;
12950 var rowsDesc=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
12951 var tableHtml='<div class="chart-section-header">SCAN HISTORY</div><table><thead><tr><th>Scan Date</th><th>Project</th><th>Commit</th><th>Branch</th><th>Tags</th><th style="text-align:right">'+esc(yL)+'</th></tr></thead><tbody>';
12952 rowsDesc.forEach(function(d){{tableHtml+='<tr><td>'+esc(d.timestamp.substring(0,16).replace('T',' '))+'</td><td>'+esc(d.project_label||'')+'</td><td>'+esc((d.commit||'').substring(0,7))+'</td><td>'+esc(d.branch||'')+'</td><td>'+esc((d.tags||[]).join(', '))+'</td><td style="text-align:right">'+fmtFull(Number(d[yK])||0)+'</td></tr>';}});
12953 tableHtml+='</tbody></table>';
12954 var css='<style>'
12955 +'*{{box-sizing:border-box;}}'
12956 +'html,body{{margin:0;padding:0;}}'
12957 // Masthead/footer flow in document order — a position:fixed header repeats
12958 // on every printed page in Chromium and hides the rows beneath it on pages
12959 // 2+. The trend table's <thead> repeats per page natively instead.
12960 +'body{{font-family:Inter,system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:#241813;background:#fff;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'
12961 +'.rep-masthead{{background:#191c26;color:#fff;display:flex;justify-content:space-between;align-items:center;padding:15px 34px;}}'
12962 +'.rep-mast-left{{display:flex;align-items:baseline;gap:14px;}}'
12963 +'.rep-mast-brand{{font-size:19px;font-weight:900;letter-spacing:-.01em;}}'
12964 +'.rep-mast-sub{{font-size:12.5px;color:rgba(255,255,255,0.65);font-weight:600;}}'
12965 +'.rep-mast-ts{{font-size:11px;color:rgba(255,255,255,0.65);font-weight:600;}}'
12966 +'.rep-body{{padding:22px 34px 0;}}'
12967 +'.rep-head{{display:flex;justify-content:space-between;align-items:flex-start;border-bottom:3px solid #C45C10;padding-bottom:14px;margin-bottom:18px;}}'
12968 +'.rep-title{{font-size:23px;font-weight:900;margin:0;color:#241813;}}'
12969 +'.rep-sub{{font-size:13px;color:#7b675b;margin:6px 0 0;}}'
12970 +'.rep-brand{{font-size:14px;font-weight:800;color:#C45C10;text-align:right;white-space:nowrap;}}'
12971 +'.rep-brand small{{display:block;font-weight:600;color:#7b675b;font-size:11px;margin-top:2px;}}'
12972 +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 22px;}}'
12973 +'.stat-chip{{border:1px solid #e6d0bf;border-radius:11px;padding:9px 12px;position:relative;background:#fcf8f3;overflow:hidden;}}'
12974 +'.stat-chip-tip{{display:none!important;}}'
12975 +'.stat-chip-val{{font-size:16px;font-weight:900;color:#C45C10;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}'
12976 +'.stat-chip-label{{font-size:8.5px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:#7b675b;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}'
12977 +'.stat-chip-exact{{position:absolute;bottom:5px;right:9px;font-size:9px;color:#7b675b;}}'
12978 +'.stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}'
12979 +'.rep-chart{{text-align:center;margin:0 0 22px;}}'
12980 +'.rep-chart svg{{max-width:100%;height:auto;}}'
12981 +'.chart-section-header{{background:#191c26;color:#fff;padding:7px 13px;border-radius:4px;font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;margin:18px 0 10px;}}'
12982 +'.filter-row{{display:none!important;}}'
12983 +'table{{border-collapse:collapse;width:100%;font-size:11px;}}'
12984 +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;}}'
12985 +'th{{background:#f0e9e0;font-weight:800;}}'
12986 +'.sort-icon,.col-resize-handle{{display:none!important;}}'
12987 +'.pagination,.table-pager,.sh-pager{{display:none!important;}}'
12988 +'.rep-foot{{margin-top:22px;background:#191c26;color:rgba(255,255,255,0.72);padding:9px 34px;font-size:11px;font-weight:600;text-align:center;line-height:1.5;}}'
12989 +'.rep-foot-gen{{margin-top:2px;color:rgba(255,255,255,0.55);}}'
12990 +'</style>';
12991 var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Trend Report</title>'+css+'</head><body>'
12992 +'<div class="rep-masthead"><div class="rep-mast-left"><span class="rep-mast-brand">oxide-sloc</span><span class="rep-mast-sub">Code Metrics Report \u00b7 Trend</span></div><div class="rep-mast-ts">Generated '+tp.date+'</div></div>'
12993 +'<div class="rep-body">'
12994 +'<div class="rep-head"><div><h1 class="rep-title">'+tp.title+'</h1><p class="rep-sub">'+tp.sub+'</p></div>'
12995 +'<div class="rep-brand">OxideSLOC<small>Trend Report</small></div></div>'
12996 +'<div class="summary-strip">'+statsHtml+'</div>'
12997 +'<div class="rep-chart">'+svgStr+'</div>'
12998 +tableHtml
12999 +'</div>'
13000 +'<div class="rep-foot"><div>\u00a9 2026 OxideSLOC \u00b7 oxide-sloc v{version} \u00b7 local code metrics workbench \u00b7 AGPL-3.0-or-later \u00b7 github.com/oxide-sloc/oxide-sloc</div><div class="rep-foot-gen">Generated '+tp.date+'</div></div>'
13001 +'</body></html>';
13002 window.slocExportPdf({{html:doc,filename:'oxide-sloc-trend-report.pdf',button:document.getElementById('export-pdf-btn')}});
13003 }}
13004
13005 ['y-sel','x-sel','scale-sel'].forEach(function(id){{
13006 var el=document.getElementById(id);
13007 if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
13008 }});
13009 // Reflow the width-filling SVG chart when the window resizes (debounced), so it
13010 // tracks the container like the responsive Chart.js charts do.
13011 var _rsT=null;
13012 window.addEventListener('resize',function(){{
13013 if(_rsT)clearTimeout(_rsT);
13014 _rsT=setTimeout(function(){{ if(allData&&allData.length)render(allData); }},150);
13015 }});
13016 rootSel.addEventListener('change',function(){{
13017 populateSubmodules(rootSel.value);
13018 loadAndRender();
13019 }});
13020 if(subSel)subSel.addEventListener('change',loadAndRender);
13021
13022 // ── Full View modal: re-render the trend chart larger using the same drawing code ──
13023 (function(){{
13024 var fvBtn=document.getElementById('tr-chart-fv-btn');
13025 if(!fvBtn)return;
13026 function closeFv(ov){{ if(ov&&ov.parentNode)ov.parentNode.removeChild(ov); hideTT(); }}
13027 fvBtn.addEventListener('click',function(){{
13028 if(!allData||!allData.length){{alert('No chart to expand yet.');return;}}
13029 var yKey=document.getElementById('y-sel').value;
13030 var xMode=document.getElementById('x-sel').value;
13031 var pts=allData;
13032 if(xMode==='tag')pts=allData.filter(function(d){{return d.tags&&d.tags.length>0;}});
13033 pts=pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
13034 if(!pts.length){{alert('No scan data found for the selected filters.');return;}}
13035 var tp=trendTitleParts();
13036 var ov=document.createElement('div');
13037 ov.className='tr-chart-full-modal';
13038 ov.innerHTML='<div class="tr-chart-full-inner">'
13039 +'<button type="button" class="settings-close" style="position:absolute;top:16px;right:18px;" aria-label="Close">'
13040 +'<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>'
13041 +'<div style="font-size:18px;font-weight:900;color:var(--oxide);margin:0 40px 2px 0;">'+esc(tp.title)+'</div>'
13042 +'<div style="font-size:12.5px;color:var(--muted);margin-bottom:16px;">'+esc(tp.sub)+'</div>'
13043 +'<div id="tr-fv-chart-wrap" class="chart-wrap"></div></div>';
13044 document.body.appendChild(ov);
13045 var fvWrap=ov.querySelector('#tr-fv-chart-wrap');
13046 renderTrendInto(fvWrap, pts, yKey, xMode, 1.7);
13047 ov.addEventListener('click',function(e){{ if(e.target===ov)closeFv(ov); }});
13048 ov.querySelector('.settings-close').addEventListener('click',function(){{closeFv(ov);}});
13049 document.addEventListener('keydown',function esc2(e){{ if(e.key==='Escape'){{closeFv(ov);document.removeEventListener('keydown',esc2);}} }});
13050 }});
13051 }})();
13052
13053 var xlsxBtn=document.getElementById('export-xlsx-btn');
13054 if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
13055 var pngBtn=document.getElementById('export-png-btn');
13056 if(pngBtn)pngBtn.addEventListener('click',exportPNG);
13057 var pdfBtn=document.getElementById('export-pdf-btn');
13058 if(pdfBtn)pdfBtn.addEventListener('click',exportPDF);
13059
13060 // ── Clean-up modal ───────────────────────────────────────────────────────
13061 (function(){{
13062 var triggerBtn=document.getElementById('cleanup-runs-btn');
13063 if(!triggerBtn)return;
13064 var modal=document.createElement('div');
13065 modal.className='tr-modal-backdrop';
13066 modal.innerHTML='<div class="tr-modal" style="max-width:520px;">'
13067 +'<div class="tr-modal-head">'
13068 +'<div class="tr-modal-icon danger"><svg viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg></div>'
13069 +'<div><h2 class="tr-modal-title">Clean up old runs</h2><p class="tr-modal-sub">One-shot deletion of older scan artifacts</p></div>'
13070 +'</div>'
13071 +'<div class="tr-modal-body">'
13072 +'<p style="font-size:13.5px;color:var(--text);margin:0 0 18px;line-height:1.5;">Delete all scan artifacts older than the chosen number of days. This removes files from disk and clears the registry. <strong>This cannot be undone.</strong></p>'
13073 +'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;">Delete runs older than</label>'
13074 +'<div style="display:flex;align-items:center;gap:8px;margin:8px 0 4px;">'
13075 +'<input type="number" id="cleanup-days-input" value="30" min="1" max="3650" style="width:90px;padding:9px 12px;border-radius:9px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:14px;font-weight:700;">'
13076 +'<span style="font-size:13px;color:var(--muted);">days</span></div>'
13077 +'<div id="cleanup-status" style="display:none;padding:10px 14px;border-radius:9px;font-size:13px;font-weight:600;margin-top:16px;"></div>'
13078 +'</div>'
13079 +'<div class="tr-modal-foot">'
13080 +'<button class="tr-btn tr-btn-secondary" id="cleanup-cancel-btn" type="button">Cancel</button>'
13081 +'<button class="tr-btn tr-btn-danger" id="cleanup-confirm-btn" type="button"><svg viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>Delete old runs</button>'
13082 +'</div></div>';
13083 document.body.appendChild(modal);
13084 triggerBtn.addEventListener('click',function(){{
13085 document.getElementById('cleanup-status').style.display='none';
13086 modal.style.display='flex';
13087 }});
13088 document.getElementById('cleanup-cancel-btn').addEventListener('click',function(){{modal.style.display='none';}});
13089 modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
13090 document.getElementById('cleanup-confirm-btn').addEventListener('click',function(){{
13091 var days=parseInt(document.getElementById('cleanup-days-input').value,10)||30;
13092 var confirmBtn=this;
13093 confirmBtn.disabled=true;
13094 var status=document.getElementById('cleanup-status');
13095 status.style.display='block';
13096 status.style.background='#dbeafe';status.style.color='#1e40af';
13097 status.textContent='Deleting\u2026';
13098 fetch('/api/runs/cleanup',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{older_than_days:days}})}})
13099 .then(function(resp){{
13100 return resp.json().then(function(d){{
13101 if(resp.ok){{
13102 status.style.background='#dcfce7';status.style.color='#166534';
13103 status.textContent='Deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+' older than '+days+' days. Refreshing\u2026';
13104 setTimeout(function(){{window.location.reload();}},1500);
13105 }}else{{
13106 status.style.background='#fee2e2';status.style.color='#991b1b';
13107 status.textContent='Error: '+(d.error||'Unexpected error');
13108 confirmBtn.disabled=false;
13109 }}
13110 }});
13111 }})
13112 .catch(function(e){{
13113 status.style.background='#fee2e2';status.style.color='#991b1b';
13114 status.textContent='Network error: '+String(e);
13115 confirmBtn.disabled=false;
13116 }});
13117 }});
13118 }})();
13119
13120 // ── Retention policy panel ────────────────────────────────────────────────
13121 (function(){{
13122 var triggerBtn=document.getElementById('retention-policy-btn');
13123 if(!triggerBtn)return;
13124 var modal=document.createElement('div');
13125 modal.className='tr-modal-backdrop';
13126 modal.style.zIndex='9001';
13127 modal.innerHTML=''
13128 +'<div class="tr-modal" style="max-width:640px;">'
13129 +'<div class="tr-modal-head">'
13130 +'<div class="tr-modal-icon"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15.5 14"/></svg></div>'
13131 +'<div><h2 class="tr-modal-title">Retention Policy</h2><p class="tr-modal-sub">Scheduled automatic cleanup of old scan runs</p></div>'
13132 +'</div>'
13133 +'<div class="tr-modal-body">'
13134 +'<p style="font-size:13px;color:var(--muted);margin:0 0 22px;">Automatically clean up old scan runs on a schedule. Both rules apply when set \u2014 a run is deleted if it exceeds the age limit <em>or</em> falls outside the count limit.</p>'
13135 +'<div style="display:flex;align-items:center;gap:10px;margin-bottom:22px;">'
13136 +'<input type="checkbox" id="rp-enabled" style="width:16px;height:16px;cursor:pointer;accent-color:var(--oxide);">'
13137 +'<label for="rp-enabled" style="font-size:14px;font-weight:700;cursor:pointer;">Enable auto-cleanup</label>'
13138 +'</div>'
13139 +'<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:20px;">'
13140 +'<div>'
13141 +'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;display:block;margin-bottom:6px;">Max age (days)</label>'
13142 +'<input type="number" id="rp-max-age" min="1" max="3650" placeholder="No limit" style="width:100%;padding:9px 12px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:14px;box-sizing:border-box;">'
13143 +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Delete runs older than N days</div>'
13144 +'</div>'
13145 +'<div>'
13146 +'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;display:block;margin-bottom:6px;">Max runs kept</label>'
13147 +'<input type="number" id="rp-max-count" min="1" max="10000" placeholder="No limit" style="width:100%;padding:9px 12px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:14px;box-sizing:border-box;">'
13148 +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Keep only the N most recent runs</div>'
13149 +'</div>'
13150 +'</div>'
13151 +'<div style="margin-bottom:20px;">'
13152 +'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;display:block;margin-bottom:6px;">Check interval</label>'
13153 +'<select id="rp-interval" style="padding:9px 12px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:14px;min-width:180px;">'
13154 +'<option value="1">Every hour</option>'
13155 +'<option value="6">Every 6 hours</option>'
13156 +'<option value="12">Every 12 hours</option>'
13157 +'<option value="24" selected>Every 24 hours</option>'
13158 +'<option value="48">Every 2 days</option>'
13159 +'<option value="72">Every 3 days</option>'
13160 +'<option value="168">Every week</option>'
13161 +'</select>'
13162 +'</div>'
13163 +'<div id="rp-last-run" style="padding:10px 14px;border-radius:8px;background:var(--surface-2);font-size:12px;color:var(--muted);margin-bottom:20px;">\u2014</div>'
13164 +'<div id="rp-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:18px;"></div>'
13165 +'</div>'
13166 +'<div class="tr-modal-foot">'
13167 +'<button class="tr-btn tr-btn-secondary" id="rp-close-btn" type="button">Close</button>'
13168 +'<button class="tr-btn tr-btn-secondary" id="rp-run-now-btn" type="button"><svg viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg>Run Now</button>'
13169 +'<button class="tr-btn tr-btn-primary" id="rp-save-btn" type="button"><svg viewBox="0 0 24 24"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save Policy</button>'
13170 +'</div>'
13171 +'</div>';
13172 document.body.appendChild(modal);
13173
13174 function rpShowStatus(msg,ok){{
13175 var s=document.getElementById('rp-status');
13176 s.style.display='block';
13177 s.style.background=ok?'#dcfce7':'#fee2e2';
13178 s.style.color=ok?'#166534':'#991b1b';
13179 s.textContent=msg;
13180 }}
13181 function fmtAgo(iso){{
13182 if(!iso)return'Never';
13183 var diff=Math.floor((Date.now()-new Date(iso).getTime())/1000);
13184 if(diff<60)return diff+'s ago';
13185 if(diff<3600)return Math.floor(diff/60)+'m ago';
13186 if(diff<86400)return Math.floor(diff/3600)+'h ago';
13187 return Math.floor(diff/86400)+'d ago';
13188 }}
13189 function loadPolicy(){{
13190 fetch('/api/cleanup-policy')
13191 .then(function(r){{return r.json();}})
13192 .then(function(d){{
13193 var p=d.policy;
13194 document.getElementById('rp-enabled').checked=p?p.enabled:false;
13195 document.getElementById('rp-max-age').value=(p&&p.max_age_days!=null)?p.max_age_days:'';
13196 document.getElementById('rp-max-count').value=(p&&p.max_run_count!=null)?p.max_run_count:'';
13197 var sel=document.getElementById('rp-interval');
13198 if(p){{var iv=String(p.interval_hours||24);for(var i=0;i<sel.options.length;i++){{if(sel.options[i].value===iv){{sel.selectedIndex=i;break;}}}}}}
13199 var lr=document.getElementById('rp-last-run');
13200 if(d.last_run_at){{
13201 lr.textContent='Last run: '+fmtAgo(d.last_run_at)+(d.last_run_deleted!=null?' \u00b7 deleted '+d.last_run_deleted+' run'+(d.last_run_deleted===1?'':'s'):'');
13202 }}else{{
13203 lr.textContent='Auto-cleanup has not run yet.';
13204 }}
13205 }})
13206 .catch(function(){{document.getElementById('rp-last-run').textContent='Could not load policy.';}});
13207 }}
13208
13209 triggerBtn.addEventListener('click',function(){{
13210 document.getElementById('rp-status').style.display='none';
13211 loadPolicy();
13212 modal.style.display='flex';
13213 }});
13214 document.getElementById('rp-close-btn').addEventListener('click',function(){{modal.style.display='none';}});
13215 modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
13216
13217 document.getElementById('rp-save-btn').addEventListener('click',function(){{
13218 var enabled=document.getElementById('rp-enabled').checked;
13219 var ageVal=document.getElementById('rp-max-age').value.trim();
13220 var countVal=document.getElementById('rp-max-count').value.trim();
13221 var intervalHours=parseInt(document.getElementById('rp-interval').value,10)||24;
13222 if(enabled&&!ageVal&&!countVal){{
13223 rpShowStatus('Set at least one rule (max age or max count) before enabling.',false);
13224 return;
13225 }}
13226 var body={{enabled:enabled,max_age_days:ageVal?parseInt(ageVal,10):null,max_run_count:countVal?parseInt(countVal,10):null,interval_hours:intervalHours}};
13227 var saveBtn=document.getElementById('rp-save-btn');
13228 saveBtn.disabled=true;
13229 fetch('/api/cleanup-policy',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify(body)}})
13230 .then(function(r){{
13231 if(r.status===204||r.ok){{rpShowStatus('Policy saved'+(enabled?'. Background task started.':'.'),true);}}
13232 else{{return r.json().then(function(d){{rpShowStatus('Error: '+(d.error||'Unexpected error'),false);}});}}
13233 }})
13234 .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
13235 .finally(function(){{saveBtn.disabled=false;}});
13236 }});
13237
13238 document.getElementById('rp-run-now-btn').addEventListener('click',function(){{
13239 var btn=this;
13240 var orig=btn.innerHTML;
13241 btn.disabled=true;
13242 btn.textContent='Running\u2026';
13243 fetch('/api/cleanup-policy/run-now',{{method:'POST'}})
13244 .then(function(r){{return r.json();}})
13245 .then(function(d){{
13246 rpShowStatus('Cleanup complete: deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+'.',true);
13247 loadPolicy();
13248 }})
13249 .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
13250 .finally(function(){{btn.disabled=false;btn.innerHTML=orig;}});
13251 }});
13252 }})();
13253
13254 populateSubmodules(rootSel.value);
13255 loadAndRender();
13256
13257 (function randomizeWatermarks() {{
13258 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
13259 if (!wms.length) return;
13260 var placed = [];
13261 function tooClose(top, left) {{
13262 for (var i = 0; i < placed.length; i++) {{
13263 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
13264 if (dt < 16 && dl < 12) return true;
13265 }}
13266 return false;
13267 }}
13268 function pick(leftBand) {{
13269 for (var attempt = 0; attempt < 50; attempt++) {{
13270 var top = Math.random() * 88 + 2;
13271 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
13272 if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
13273 }}
13274 var top = Math.random() * 88 + 2;
13275 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
13276 placed.push([top, left]); return [top, left];
13277 }}
13278 var half = Math.floor(wms.length / 2);
13279 wms.forEach(function (img, i) {{
13280 var pos = pick(i < half);
13281 var size = Math.floor(Math.random() * 100 + 120);
13282 var rot = (Math.random() * 360).toFixed(1);
13283 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
13284 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;
13285 }});
13286 }})();
13287 (function spawnCodeParticles() {{
13288 var container = document.getElementById('code-particles');
13289 if (!container) return;
13290 var snippets = [
13291 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
13292 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
13293 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
13294 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
13295 'fn main() {{','.rs .go .py','sloc_core','render_html','2,163 code'
13296 ];
13297 var count = 38;
13298 for (var i = 0; i < count; i++) {{
13299 (function(idx) {{
13300 var el = document.createElement('span');
13301 el.className = 'code-particle';
13302 el.textContent = snippets[idx % snippets.length];
13303 var left = Math.random() * 94 + 2;
13304 var top = Math.random() * 88 + 6;
13305 var dur = (Math.random() * 10 + 9).toFixed(1);
13306 var delay = (Math.random() * 18).toFixed(1);
13307 var rot = (Math.random() * 26 - 13).toFixed(1);
13308 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
13309 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
13310 container.appendChild(el);
13311 }})(i);
13312 }}
13313 }})();
13314 </script>
13315 <footer class="site-footer">
13316 local code analysis - metrics, history and reports
13317 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Local</em>
13318 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
13319 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
13320 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
13321 · <a href="/api-docs" rel="noopener">REST API</a>
13322 </footer>
13323 <script nonce="{nonce}">(function(){{var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{version} \u2014 Mode: '+(isServer?'Network Server':'Local');function setDot(ms){{if(!dot)return;if(ms<100){{dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}}else if(ms<300){{dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}}else{{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}}function doPing(){{var t0=performance.now();fetch('/healthz',{{cache:'no-store'}}).then(function(){{var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}}).catch(function(){{if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}});}}doPing();setInterval(doPing,5000);}})();</script>
13324 {toast_assets}
13325</body>
13326</html>"##,
13327 );
13328
13329 Html(html).into_response()
13330}
13331
13332fn compute_cov_pct_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
13333 use std::collections::HashMap;
13334 if !per_file_records.iter().any(|f| f.coverage.is_some()) {
13335 return vec![];
13336 }
13337 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
13338 for rec in per_file_records {
13339 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
13340 let e = totals.entry(lang.display_name().to_string()).or_default();
13341 e.0 += u64::from(cov.lines_found);
13342 e.1 += u64::from(cov.lines_hit);
13343 }
13344 }
13345 #[allow(clippy::cast_precision_loss)] let mut pairs: Vec<(String, f64)> = totals
13347 .into_iter()
13348 .filter(|(_, (found, _))| *found > 0)
13349 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
13350 .collect();
13351 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
13352 pairs
13353 .iter()
13354 .map(|(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}))
13355 .collect()
13356}
13357
13358fn compute_cov_tiers(per_file_records: &[sloc_core::FileRecord]) -> (u64, u64, u64) {
13359 let mut high = 0u64;
13360 let mut mid = 0u64;
13361 let mut low = 0u64;
13362 for rec in per_file_records {
13363 if let Some(cov) = &rec.coverage {
13364 if cov.lines_found == 0 {
13365 continue;
13366 }
13367 let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
13368 if pct >= 80.0 {
13369 high += 1;
13370 } else if pct >= 50.0 {
13371 mid += 1;
13372 } else {
13373 low += 1;
13374 }
13375 }
13376 }
13377 (high, mid, low)
13378}
13379
13380fn compute_file_cov_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
13381 let mut arr: Vec<serde_json::Value> = per_file_records
13382 .iter()
13383 .filter_map(|rec| {
13384 rec.coverage.as_ref().map(|cov| {
13385 let line_pct = if cov.lines_found > 0 {
13386 (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
13387 / 10.0
13388 } else {
13389 0.0
13390 };
13391 let fn_pct = if cov.functions_found > 0 {
13392 (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
13393 .round()
13394 / 10.0
13395 } else {
13396 -1.0
13397 };
13398 serde_json::json!({
13399 "rel": rec.relative_path,
13400 "lang": rec.language.map_or("?", |l| l.display_name()),
13401 "line_pct": line_pct,
13402 "fn_pct": fn_pct,
13403 "lhit": cov.lines_hit,
13404 "lfound": cov.lines_found,
13405 "fhit": cov.functions_hit,
13406 "ffound": cov.functions_found,
13407 })
13408 })
13409 })
13410 .collect();
13411 arr.sort_by(|a, b| {
13412 let pa = a["line_pct"].as_f64().unwrap_or(0.0);
13413 let pb = b["line_pct"].as_f64().unwrap_or(0.0);
13414 pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
13415 });
13416 arr
13417}
13418
13419#[allow(clippy::cast_precision_loss)] fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
13421 let mut langs: Vec<&sloc_core::LanguageSummary> = run
13422 .totals_by_language
13423 .iter()
13424 .filter(|l| l.test_count > 0)
13425 .collect();
13426 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13427 let lang_tests: Vec<serde_json::Value> = langs
13428 .iter()
13429 .map(|l| {
13430 let d = if l.code_lines > 0 {
13431 l.test_count as f64 / l.code_lines as f64 * 1000.0
13432 } else {
13433 0.0
13434 };
13435 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
13436 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
13437 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
13438 })
13439 .collect();
13440 let cov_arr = compute_cov_pct_arr(&run.per_file_records);
13441 let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
13442 let t = &run.summary_totals;
13443 let total_tests = t.test_count;
13444 let density = if t.code_lines > 0 {
13445 total_tests as f64 / t.code_lines as f64 * 1000.0
13446 } else {
13447 0.0
13448 };
13449 let most_tested = langs.first().map_or_else(
13450 || "\u{2014}".to_string(),
13451 |l| l.language.display_name().to_string(),
13452 );
13453 let test_files: u64 = run
13454 .per_file_records
13455 .iter()
13456 .filter(|f| f.raw_line_categories.test_count > 0)
13457 .count() as u64;
13458 let cov_line = if t.coverage_lines_found > 0 {
13459 format!(
13460 "{:.1}",
13461 t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
13462 )
13463 } else {
13464 "0".to_string()
13465 };
13466 let cov_fn = if t.coverage_functions_found > 0 {
13467 format!(
13468 "{:.1}",
13469 t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
13470 )
13471 } else {
13472 "0".to_string()
13473 };
13474 let cov_branch = if t.coverage_branches_found > 0 {
13475 format!(
13476 "{:.1}",
13477 t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
13478 )
13479 } else {
13480 "0".to_string()
13481 };
13482 let has_cov = !cov_arr.is_empty();
13483 let file_cov_arr = compute_file_cov_arr(&run.per_file_records);
13484 serde_json::json!({
13485 "totals": {
13486 "test_count": total_tests,
13487 "assertions": t.test_assertion_count,
13488 "suites": t.test_suite_count,
13489 "test_files": test_files,
13490 "total_files": t.files_analyzed,
13491 "density_str": format!("{density:.1}"),
13492 "most_tested": most_tested,
13493 "langs_with_tests": langs.len(),
13494 "cov_line": cov_line,
13495 "cov_fn": cov_fn,
13496 "cov_branch": cov_branch,
13497 },
13498 "lang_tests": lang_tests,
13499 "cov": cov_arr,
13500 "cov_tiers": {"high": high, "mid": mid, "low": low},
13501 "file_cov": file_cov_arr,
13502 "has_coverage": has_cov,
13503 "submodules": {},
13504 })
13505}
13506
13507#[allow(clippy::cast_precision_loss)] fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
13509 let mut langs: Vec<&sloc_core::LanguageSummary> = sub
13510 .language_summaries
13511 .iter()
13512 .filter(|l| l.test_count > 0)
13513 .collect();
13514 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13515 let lang_tests: Vec<serde_json::Value> = langs
13516 .iter()
13517 .map(|l| {
13518 let d = if l.code_lines > 0 {
13519 l.test_count as f64 / l.code_lines as f64 * 1000.0
13520 } else {
13521 0.0
13522 };
13523 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
13524 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
13525 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
13526 })
13527 .collect();
13528 let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
13529 let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
13530 let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
13531 let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
13532 let density = if sub.code_lines > 0 {
13533 total_tests as f64 / sub.code_lines as f64 * 1000.0
13534 } else {
13535 0.0
13536 };
13537 let most_tested = langs.first().map_or_else(
13538 || "\u{2014}".to_string(),
13539 |l| l.language.display_name().to_string(),
13540 );
13541 serde_json::json!({
13542 "totals": {
13543 "test_count": total_tests,
13544 "assertions": total_assertions,
13545 "suites": total_suites,
13546 "test_files": test_files_approx,
13547 "total_files": sub.files_analyzed,
13548 "density_str": format!("{density:.1}"),
13549 "most_tested": most_tested,
13550 "langs_with_tests": langs.len(),
13551 "cov_line": "0",
13552 "cov_fn": "0",
13553 "cov_branch": "0",
13554 },
13555 "lang_tests": lang_tests,
13556 "cov": [],
13557 "cov_tiers": {"high": 0, "mid": 0, "low": 0},
13558 "has_coverage": false,
13559 })
13560}
13561
13562fn compute_cov_json_str(run: &AnalysisRun) -> String {
13563 use std::collections::HashMap;
13564 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
13565 for rec in &run.per_file_records {
13566 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
13567 let e = totals.entry(lang.display_name().to_string()).or_default();
13568 e.0 += u64::from(cov.lines_found);
13569 e.1 += u64::from(cov.lines_hit);
13570 }
13571 }
13572 #[allow(clippy::cast_precision_loss)] let mut pairs: Vec<(String, f64)> = totals
13574 .into_iter()
13575 .filter(|(_, (found, _))| *found > 0)
13576 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
13577 .collect();
13578 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
13579 let parts: Vec<String> = pairs
13580 .iter()
13581 .map(|(lang, pct)| {
13582 let name = lang.replace('"', "\\\"");
13583 format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
13584 })
13585 .collect();
13586 format!("[{}]", parts.join(","))
13587}
13588
13589fn compute_cov_tier_json_str(run: &AnalysisRun) -> String {
13590 let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
13591 format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
13592}
13593
13594fn build_scope_entry_for_run(run: &AnalysisRun) -> serde_json::Value {
13595 let mut entry = build_test_scope_entry(run);
13596 if !run.submodule_summaries.is_empty() {
13597 let subs: serde_json::Map<String, serde_json::Value> = run
13598 .submodule_summaries
13599 .iter()
13600 .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
13601 .collect();
13602 entry["submodules"] = serde_json::Value::Object(subs);
13603 }
13604 entry
13605}
13606
13607fn lang_test_entry_json(l: &sloc_core::LanguageSummary) -> String {
13608 let name = l.language.display_name().replace('"', "\\\"");
13609 #[allow(clippy::cast_precision_loss)] let density = if l.code_lines > 0 {
13611 l.test_count as f64 / l.code_lines as f64 * 1000.0
13612 } else {
13613 0.0
13614 };
13615 format!(
13616 r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
13617 name = name,
13618 t = l.test_count,
13619 a = l.test_assertion_count,
13620 s = l.test_suite_count,
13621 c = l.code_lines,
13622 d = density,
13623 f = l.files,
13624 )
13625}
13626
13627fn build_lang_tests_json(run: Option<&AnalysisRun>) -> String {
13628 let Some(r) = run else {
13629 return "[]".to_string();
13630 };
13631 let mut langs: Vec<&sloc_core::LanguageSummary> = r
13632 .totals_by_language
13633 .iter()
13634 .filter(|l| l.test_count > 0)
13635 .collect();
13636 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13637 let parts: Vec<String> = langs.iter().map(|l| lang_test_entry_json(l)).collect();
13638 format!("[{}]", parts.join(","))
13639}
13640
13641async fn build_scope_data_json(state: &AppState, latest_run: Option<&AnalysisRun>) -> String {
13643 let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
13644 scope_map.insert(
13645 "__all__".to_string(),
13646 latest_run.map_or_else(
13647 || {
13648 serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
13649 "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"\u{2014}",
13650 "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
13651 "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
13652 "has_coverage":false,"submodules":{}})
13653 },
13654 build_test_scope_entry,
13655 ),
13656 );
13657 let all_roots: Vec<String> = {
13658 let reg = state.registry.lock().await;
13659 let mut seen = std::collections::BTreeSet::new();
13660 reg.entries
13661 .iter()
13662 .flat_map(|e| e.input_roots.iter().cloned())
13663 .filter(|r| seen.insert(r.clone()))
13664 .collect()
13665 };
13666 for root in &all_roots {
13667 let json_path = {
13668 let reg = state.registry.lock().await;
13669 reg.entries
13670 .iter()
13671 .find(|e| e.input_roots.iter().any(|r| r == root))
13672 .and_then(|e| e.json_path.clone())
13673 };
13674 let run_for_root: Option<AnalysisRun> = if let Some(p) = json_path {
13675 let json_str = tokio::fs::read_to_string(&p).await.ok();
13676 json_str
13677 .as_deref()
13678 .and_then(|s| serde_json::from_str(s).ok())
13679 } else {
13680 None
13681 };
13682 if let Some(ref run) = run_for_root {
13683 scope_map.insert(root.clone(), build_scope_entry_for_run(run));
13684 }
13685 }
13686 serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
13687}
13688
13689#[allow(clippy::cast_precision_loss)] #[allow(clippy::too_many_lines)] async fn test_metrics_handler(
13693 State(state): State<AppState>,
13694 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
13695) -> Response {
13696 auto_scan_watched_dirs(&state).await;
13697 let watched_dirs_list: Vec<String> = {
13698 let wd = state.watched_dirs.lock().await;
13699 wd.dirs.iter().map(|p| p.display().to_string()).collect()
13700 };
13701 let latest_run: Option<AnalysisRun> = {
13702 let json_path = {
13703 let reg = state.registry.lock().await;
13704 reg.entries.first().and_then(|e| e.json_path.clone())
13705 };
13706 if let Some(p) = json_path {
13707 let json_str = tokio::fs::read_to_string(&p).await.ok();
13708 json_str
13709 .as_deref()
13710 .and_then(|s| serde_json::from_str(s).ok())
13711 } else {
13712 None
13713 }
13714 };
13715
13716 let _lang_tests_json = build_lang_tests_json(latest_run.as_ref());
13718
13719 let cov_json: String = latest_run
13721 .as_ref()
13722 .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
13723 .map_or_else(|| "[]".to_string(), compute_cov_json_str);
13724
13725 let _cov_tier_json: String = latest_run
13727 .as_ref()
13728 .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
13729 .map_or_else(
13730 || r#"{"high":0,"mid":0,"low":0}"#.to_string(),
13731 compute_cov_tier_json_str,
13732 );
13733
13734 let total_tests: u64 = latest_run
13735 .as_ref()
13736 .map_or(0, |r| r.summary_totals.test_count);
13737 let total_assertions: u64 = latest_run
13738 .as_ref()
13739 .map_or(0, |r| r.summary_totals.test_assertion_count);
13740 let total_suites: u64 = latest_run
13741 .as_ref()
13742 .map_or(0, |r| r.summary_totals.test_suite_count);
13743 let total_code: u64 = latest_run
13744 .as_ref()
13745 .map_or(0, |r| r.summary_totals.code_lines);
13746 let workspace_density: f64 = if total_code > 0 {
13747 total_tests as f64 / total_code as f64 * 1000.0
13748 } else {
13749 0.0
13750 };
13751 let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
13752 r.totals_by_language
13753 .iter()
13754 .filter(|l| l.test_count > 0)
13755 .count()
13756 });
13757 let most_tested: String = latest_run
13758 .as_ref()
13759 .and_then(|r| {
13760 r.totals_by_language
13761 .iter()
13762 .filter(|l| l.test_count > 0)
13763 .max_by_key(|l| l.test_count)
13764 })
13765 .map_or_else(
13766 || "\u{2014}".to_string(),
13767 |l| l.language.display_name().to_string(),
13768 );
13769 let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
13770 r.per_file_records
13771 .iter()
13772 .filter(|f| f.raw_line_categories.test_count > 0)
13773 .count() as u64
13774 });
13775 let total_files_analyzed: u64 = latest_run
13776 .as_ref()
13777 .map_or(0, |r| r.summary_totals.files_analyzed);
13778 let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
13779
13780 let cov_line_pct_str: String = latest_run
13782 .as_ref()
13783 .filter(|r| r.summary_totals.coverage_lines_found > 0)
13784 .map_or_else(
13785 || "0".to_string(),
13786 |r| {
13787 format!(
13788 "{:.1}",
13789 r.summary_totals.coverage_lines_hit as f64
13790 / r.summary_totals.coverage_lines_found as f64
13791 * 100.0
13792 )
13793 },
13794 );
13795 let cov_fn_pct_str: String = latest_run
13796 .as_ref()
13797 .filter(|r| r.summary_totals.coverage_functions_found > 0)
13798 .map_or_else(
13799 || "0".to_string(),
13800 |r| {
13801 format!(
13802 "{:.1}",
13803 r.summary_totals.coverage_functions_hit as f64
13804 / r.summary_totals.coverage_functions_found as f64
13805 * 100.0
13806 )
13807 },
13808 );
13809 let cov_branch_pct_str: String = latest_run
13810 .as_ref()
13811 .filter(|r| r.summary_totals.coverage_branches_found > 0)
13812 .map_or_else(
13813 || "0".to_string(),
13814 |r| {
13815 format!(
13816 "{:.1}",
13817 r.summary_totals.coverage_branches_hit as f64
13818 / r.summary_totals.coverage_branches_found as f64
13819 * 100.0
13820 )
13821 },
13822 );
13823
13824 let cov_no_data_notice = if has_coverage {
13825 String::new()
13826 } else {
13827 String::from(
13828 r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
13829<div style="margin-bottom:10px;font-size:14px;">No code coverage data found for the latest scan. Re-run with a coverage file to enable line, function, and branch coverage metrics.</div>
13830<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
13831 <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
13832 <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>LCOV</strong> <code>.info</code></span>
13833 <span style="color:var(--muted);font-size:12px;">·</span>
13834 <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>Cobertura XML</strong></span>
13835 <span style="color:var(--muted);font-size:12px;">·</span>
13836 <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>JaCoCo XML</strong></span>
13837 <span style="color:var(--muted);font-size:12px;">·</span>
13838 <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>coverage.py JSON</strong></span>
13839 <span style="color:var(--muted);font-size:12px;">·</span>
13840 <span style="background:var(--surface-2);border:1px solid var(--line-strong);border-radius:6px;padding:3px 9px;font-size:12px;white-space:nowrap;"><strong>Istanbul JSON</strong></span>
13841</div>
13842<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
13843</div>"#,
13844 )
13845 };
13846
13847 let workspace_density_str = format!("{workspace_density:.1}");
13848 let nonce = &csp_nonce;
13849 let toast_assets = sloc_toast_assets(nonce);
13850 let version = env!("CARGO_PKG_VERSION");
13851
13852 let watched_dirs_html: String = if state.server_mode {
13855 r#"<div class="watched-bar"><div class="watched-bar-left"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="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><span class="watched-label">Watched Folders</span><div class="watched-chips"><span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span></div></div></div>"#.to_string()
13856 } else {
13857 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
13858 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
13859 .to_string()
13860 } else {
13861 watched_dirs_list
13862 .iter()
13863 .fold(String::new(), |mut s, d| {
13864 use std::fmt::Write as _;
13865 let escaped =
13866 d.replace('&', "&").replace('"', """).replace('<', "<");
13867 write!(
13868 s,
13869 r#"<span class="watched-chip"><span class="watched-chip-path" title="{escaped}">{escaped}</span><form method="POST" action="/watched-dirs/remove" style="display:contents"><input type="hidden" name="folder_path" value="{escaped}"><input type="hidden" name="redirect_to" value="/test-metrics"><button type="submit" class="watched-chip-rm" title="Remove folder">✕</button></form></span>"#
13870 ).expect("write to String is infallible");
13871 s
13872 })
13873 };
13874 format!(
13875 r#"<div class="watched-bar" id="watched-bar"><div class="watched-bar-left"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="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><span class="watched-label">Watched Folders</span><div class="watched-chips">{watched_dirs_chips}</div></div><div class="watched-bar-right"><button type="button" class="btn" id="add-watched-btn"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg> Choose</button><form method="POST" action="/watched-dirs/refresh" style="display:contents"><input type="hidden" name="redirect_to" value="/test-metrics"><button type="submit" class="btn">↻ Refresh</button></form></div></div>"#
13876 )
13877 };
13878
13879 let scope_data_json = build_scope_data_json(&state, latest_run.as_ref()).await;
13881
13882 let html = format!(
13883 r#"<!doctype html>
13884<html lang="en">
13885<head>
13886 <meta charset="utf-8" />
13887 <meta name="viewport" content="width=device-width, initial-scale=1" />
13888 <title>OxideSLOC | Test Metrics</title>
13889 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
13890 <style nonce="{nonce}">
13891 :root {{
13892 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
13893 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
13894 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
13895 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
13896 --info-bg:#eef3ff; --info-text:#4467d8;
13897 }}
13898 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
13899 *{{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);}} body{{display:flex;flex-direction:column;}}
13900 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
13901 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
13902 .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;}}
13903 @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));}}}}
13904 .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);}}
13905 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
13906 .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}} .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));}}
13907 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
13908 .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;white-space:nowrap;}}
13909 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
13910 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
13911 @media (max-width:1150px) {{ .nav-right {{ gap:4px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 8px;font-size:11px;min-height:34px; }} .brand-subtitle {{ display:none; }} .server-online-pill {{ width:34px;padding:0;justify-content:center;font-size:0;gap:0;min-height:34px; }} }}
13912 .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;white-space:nowrap;text-decoration:none;transition:background .15s ease,transform .15s ease;}}
13913 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
13914 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
13915 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
13916 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
13917 .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;}}
13918 .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;}}
13919 .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;white-space:nowrap;text-decoration:none;}}.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;}}
13920 .settings-modal{{position:fixed;z-index:9999;background:var(--surface-2);border:1px solid var(--line-strong);border-radius:14px;box-shadow:0 12px 36px rgba(0,0,0,0.22);min-width:260px;max-width:320px;opacity:0;pointer-events:none;transform:translateY(-8px) scale(0.97);transition:opacity 0.18s ease,transform 0.18s ease;overflow:hidden;}}
13921 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
13922 .settings-modal-header{{display:flex;align-items:center;justify-content:space-between;padding:14px 16px 10px;border-bottom:1px solid var(--line);font-size:13px;font-weight:800;color:var(--text);}}
13923 .settings-close{{background:none;border:none;cursor:pointer;width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--muted);border-radius:6px;padding:0;}}
13924 .settings-close:hover{{color:var(--text);background:var(--surface-2);}} .settings-close svg{{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}}
13925 .settings-modal-body{{padding:14px 16px 16px;}} .settings-modal-label{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}}
13926 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
13927 .scheme-swatch{{display:flex;flex-direction:column;align-items:center;gap:5px;background:none;border:1.5px solid var(--line);border-radius:10px;cursor:pointer;padding:7px 4px 6px;transition:border-color 0.15s ease,transform 0.12s ease;}}
13928 .scheme-swatch:hover{{border-color:var(--line-strong);transform:translateY(-1px);}} .scheme-swatch.active{{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}}
13929 .scheme-preview{{width:28px;height:28px;border-radius:7px;flex-shrink:0;}} .scheme-label{{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}}
13930 .tz-select{{width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2);color:var(--text);font-size:12px;font-weight:600;cursor:pointer;outline:none;box-sizing:border-box;}}
13931 .tz-select:focus{{border-color:var(--oxide);}}
13932 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
13933 @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
13934 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
13935 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
13936 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
13937 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
13938 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
13939 .stat-chip{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px 16px;position:relative;cursor:default;transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1);}}
13940 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
13941 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
13942 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
13943 .stat-chip-exact{{position:absolute;bottom:6px;right:10px;font-size:12px;font-weight:600;color:var(--muted);font-variant-numeric:tabular-nums;line-height:1;}}
13944 .stat-chip-tip{{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%) translateY(-7px);background:var(--text);color:var(--bg);padding:7px 12px;border-radius:8px;font-size:11px;line-height:1.6;white-space:normal;max-width:280px;pointer-events:none;opacity:0;transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1);z-index:200;}}
13945 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
13946 .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
13947 .section-header{{font-size:13px;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.07em;margin:22px 0 10px;padding-top:16px;border-top:1px solid var(--line);}}
13948 .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
13949 .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
13950 @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
13951 .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
13952 .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
13953 .chart-canvas-wrap{{position:relative;height:280px;}}
13954 .chart-no-data{{display:flex;flex-direction:column;align-items:center;justify-content:center;height:200px;border:1px dashed var(--line-strong);border-radius:10px;color:var(--muted);font-size:13px;gap:10px;}}
13955 .chart-no-data svg{{opacity:0.35;}}
13956 .chart-no-data-title{{font-weight:700;font-size:13px;color:var(--muted-2);}}
13957 .chart-no-data-hint{{font-size:11px;color:var(--muted);text-align:center;max-width:220px;line-height:1.5;}}
13958 .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
13959 .data-table 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;}}
13960 .data-table td{{text-align:left;padding:9px 12px;border-bottom:1px solid var(--line);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle;}}
13961 .data-table tr:last-child td{{border-bottom:none;}}
13962 .data-table tbody tr:hover td{{background:var(--surface-2);}}
13963 .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
13964 .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
13965 .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
13966 .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
13967 .cov-gauge-card{{position:relative;background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:18px 20px;display:flex;flex-direction:column;gap:8px;transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1);min-width:0;}}
13968 .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
13969 .cov-gauge-tip{{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%) translateY(-7px);background:var(--text);color:var(--bg);padding:10px 14px;border-radius:8px;font-size:11px;font-weight:500;line-height:1.55;white-space:normal;max-width:300px;min-width:180px;text-align:left;pointer-events:none;opacity:0;transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1);z-index:200;box-shadow:0 4px 14px rgba(0,0,0,0.2);}}
13970 .cov-gauge-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
13971 .cov-gauge-card:hover .cov-gauge-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
13972 .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
13973 .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
13974 .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
13975 .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
13976 .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
13977 @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
13978 .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
13979 .chart-select{{background:var(--surface-2);border:1px solid var(--line-strong);border-radius:8px;padding:5px 10px;color:var(--text);font-size:13px;font-weight:600;cursor:pointer;outline:none;}}
13980 .chart-select:focus{{border-color:var(--accent);}}
13981 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
13982 .trend-canvas-wrap{{position:relative;height:260px;}}
13983 .trend-controls-bar{{display:flex;justify-content:center;align-items:center;gap:20px;flex-wrap:wrap;padding:13px 0 15px;border-top:1px solid var(--line);border-bottom:1px solid var(--line);margin-bottom:16px;}}
13984 .trend-controls-bar label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
13985 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
13986 .site-footer a{{color:var(--muted);}}
13987 body.dark-theme .chart-box{{border-color:var(--line-strong);}}
13988 .btn{{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border-radius:7px;border:1px solid var(--line-strong);background:var(--surface);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap;transition:background .13s;}}
13989 .btn:hover{{background:var(--surface-2);}}
13990 .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;}}
13991 .export-btn:hover{{background:var(--line);}}
13992 .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
13993 /* Page-level export controls (Scope toolbar, right-aligned) — identical style to View Reports */
13994 .export-group{{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}}
13995 .scope-export{{margin-left:auto;}}
13996 body.pdf-mode .export-group{{display:none!important;}}
13997 @media (max-width:720px){{.scope-export{{margin-left:0;width:100%;}}}}
13998 .scope-bar{{display:flex;align-items:center;gap:12px;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:8px 12px;margin-bottom:14px;position:relative;z-index:1;flex-wrap:wrap;}}
13999 .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
14000 .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
14001 .scope-sel{{background:var(--surface-2);border:1px solid var(--line-strong);border-radius:7px;padding:5px 10px;color:var(--text);font-size:12px;font-weight:600;cursor:pointer;outline:none;max-width:500px;}}
14002 .scope-sel:focus{{border-color:var(--accent);}}
14003 body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
14004 .watched-bar{{display:flex;align-items:center;gap:10px;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:8px 12px;flex-wrap:wrap;margin-bottom:14px;position:relative;z-index:1;}}
14005 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
14006 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
14007 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
14008 .watched-chip{{display:inline-flex;align-items:center;gap:4px;background:var(--surface-2);border:1px solid var(--line);border-radius:6px;padding:3px 6px 3px 8px;font-size:11px;max-width:300px;}}
14009 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
14010 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
14011 .watched-chip-rm:hover{{color:var(--oxide);}}
14012 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
14013 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
14014 .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
14015 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
14016 .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
14017 .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
14018 .cov-tab{{padding:4px 12px;border-radius:20px;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--muted);font-size:11px;font-weight:700;cursor:pointer;transition:background .12s,color .12s;white-space:nowrap;}}
14019 .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
14020 .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
14021 .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
14022 .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
14023 .cov-file-search{{flex:1;min-width:160px;max-width:340px;background:var(--surface-2);border:1px solid var(--line-strong);border-radius:7px;padding:5px 10px;color:var(--text);font-size:12px;outline:none;}}
14024 .cov-file-search:focus{{border-color:var(--accent);}}
14025 .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
14026 .cov-file-path{{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;color:var(--text);max-width:520px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
14027 body.dark-theme .cov-file-search{{background:var(--surface);}}
14028 .chart-box-header{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
14029 .chart-expand-btn{{background:none;border:1px solid var(--line-strong);border-radius:6px;cursor:pointer;color:var(--muted);padding:4px 10px;font-size:13px;line-height:1;transition:background .13s,color .13s;flex-shrink:0;white-space:nowrap;}}
14030 .chart-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
14031 .chart-modal-overlay{{position:fixed;inset:0;background:rgba(0,0,0,0.55);z-index:9999;display:flex;align-items:center;justify-content:center;padding:24px;box-sizing:border-box;}}
14032 .chart-modal{{background:var(--bg);border-radius:16px;padding:24px 28px;max-width:1200px;width:100%;max-height:88vh;overflow-y:auto;position:relative;box-shadow:0 24px 80px rgba(0,0,0,0.3);}}
14033 .chart-modal-title{{font-size:15px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:var(--text);margin:0 0 2px;display:block;}}
14034 .chart-modal-subtitle{{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 16px;display:block;letter-spacing:.02em;}}
14035 .chart-modal-close{{position:absolute;top:14px;right:18px;background:none;border:none;font-size:22px;cursor:pointer;color:var(--text);line-height:1;padding:0;}}
14036 .chart-modal-close:hover{{opacity:.7;}}
14037 body.dark-theme .chart-modal{{background:var(--surface);}}
14038 </style>
14039</head>
14040<body>
14041 <div class="background-watermarks" aria-hidden="true">
14042 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14043 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14044 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14045 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14046 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14047 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14048 </div>
14049 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
14050 <div class="top-nav">
14051 <div class="top-nav-inner">
14052 <a class="brand" href="/">
14053 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
14054 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
14055 </a>
14056 <div class="nav-right">
14057 <a class="nav-pill" href="/">Home</a>
14058 <div class="nav-dropdown">
14059 <a href="/view-reports" class="nav-dropdown-btn">View Reports <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></a>
14060 <div class="nav-dropdown-menu">
14061 <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
14062 </div>
14063 </div>
14064 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
14065 <a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
14066 <div class="nav-dropdown">
14067 <a href="/git-browser" class="nav-dropdown-btn">Git Browser <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></a>
14068 <div class="nav-dropdown-menu">
14069 <a href="/integrations"><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>Integrations</a>
14070 </div>
14071 </div>
14072 <div class="server-status-wrap" id="server-status-wrap">
14073 <div class="nav-pill server-online-pill" id="server-status-pill">
14074 <span class="status-dot" id="status-dot"></span>
14075 <span id="server-status-label">Server</span>
14076 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
14077 </div>
14078 <div class="server-status-tip">
14079 OxideSLOC is running — accessible on your network.
14080 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
14081 </div>
14082 </div>
14083 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
14084 <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
14085 </button>
14086 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
14087 <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>
14088 <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>
14089 </button>
14090 </div>
14091 </div>
14092 </div>
14093
14094 <div class="page">
14095 {watched_dirs_html}
14096 <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
14097 <div class="scan-overlay-card">
14098 <div class="scan-spinner"></div>
14099 <div class="scan-overlay-text">Scanning folder…</div>
14100 <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
14101 </div>
14102 </div>
14103 <style>
14104 .scan-overlay{{position:fixed;inset:0;z-index:12000;display:none;align-items:center;justify-content:center;background:rgba(20,12,8,0.5);backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);}}
14105 .scan-overlay.active{{display:flex;}}
14106 .scan-overlay-card{{background:var(--surface);border:1px solid var(--line-strong);border-radius:16px;padding:26px 38px;display:flex;flex-direction:column;align-items:center;gap:12px;box-shadow:0 24px 60px rgba(0,0,0,0.35);max-width:340px;text-align:center;}}
14107 .scan-spinner{{width:42px;height:42px;border-radius:50%;border:4px solid var(--line);border-top-color:var(--oxide);animation:scanSpin 0.8s linear infinite;}}
14108 @keyframes scanSpin{{to{{transform:rotate(360deg);}}}}
14109 .scan-overlay-text{{font-size:15px;font-weight:800;color:var(--text);}}
14110 .scan-overlay-sub{{font-size:12px;color:var(--muted);line-height:1.5;}}
14111 </style>
14112 <div class="scope-bar">
14113 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;color:var(--muted);"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
14114 <span class="scope-label">Scope</span>
14115 <div class="scope-sel-wrap">
14116 <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
14117 <div id="scope-sub-wrap" style="display:none;align-items:center;gap:16px;padding-left:16px;margin-left:4px;border-left:1.5px solid var(--line-strong);">
14118 <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;color:var(--muted);display:flex;align-self:center;margin-top:3px;"><line x1="6" y1="3" x2="6" y2="15"></line><circle cx="18" cy="6" r="3"></circle><circle cx="6" cy="18" r="3"></circle><path d="M18 9a9 9 0 0 1-9 9"></path></svg>
14119 <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
14120 </div>
14121 </div>
14122 <!-- Page-level export: covers the whole page (Test Metrics + LCOV Coverage Summary) for the selected scope. -->
14123 <div class="export-group scope-export" id="tm-export-group">
14124 <button type="button" class="export-btn" id="tm-export-xlsx-btn" title="Download the whole page (Test Metrics + LCOV Coverage Summary) as an Excel workbook (.xlsx)">
14125 <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>
14126 Export Excel
14127 </button>
14128 <button type="button" class="export-btn" id="tm-export-png-btn" title="Save the whole page's charts as a PNG image">
14129 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
14130 Export PNG
14131 </button>
14132 <button type="button" class="export-btn" id="tm-export-pdf-btn" title="Export the whole page as a printable PDF report">
14133 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="13" y2="17"/></svg>
14134 Export PDF
14135 </button>
14136 </div>
14137 </div>
14138 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
14139 <div class="stat-chip"><div class="stat-chip-val" id="chip-total">{total_tests}</div><div class="stat-chip-label">Test Functions</div><div class="stat-chip-tip">Lexically detected test case / function definitions (GTest, PyTest, JUnit, Unity, etc.)</div><div class="stat-chip-exact" id="chip-total-exact"></div></div>
14140 <div class="stat-chip"><div class="stat-chip-val" id="chip-assertions">{total_assertions}</div><div class="stat-chip-label">Assertions</div><div class="stat-chip-tip">Test assertion call lines (ASSERT_EQ, EXPECT_TRUE, assertEquals, Assert.AreEqual, assert_eq!, etc.)</div><div class="stat-chip-exact" id="chip-assertions-exact"></div></div>
14141 <div class="stat-chip"><div class="stat-chip-val" id="chip-suites">{total_suites}</div><div class="stat-chip-label">Test Suites</div><div class="stat-chip-tip">Test suite / fixture / group declarations (TEST_GROUP, BOOST_AUTO_TEST_SUITE, [TestClass], etc.)</div></div>
14142 <div class="stat-chip"><div class="stat-chip-val" id="chip-test-files">{test_files_count} / {total_files_analyzed}</div><div class="stat-chip-label">Test Files</div><div class="stat-chip-tip">Files containing at least one test definition out of total analyzed files</div><div class="stat-chip-exact" id="chip-test-files-exact"></div></div>
14143 </div>
14144 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
14145 <div class="stat-chip"><div class="stat-chip-val" id="chip-density">{workspace_density_str}</div><div class="stat-chip-label">Tests per 1K SLOC</div><div class="stat-chip-tip">Workspace-wide test density: test functions ÷ code lines × 1000</div></div>
14146 <div class="stat-chip"><div class="stat-chip-val" id="chip-most">{most_tested}</div><div class="stat-chip-label">Most Tested Language</div><div class="stat-chip-tip">Language with the highest absolute test function count</div></div>
14147 <div class="stat-chip"><div class="stat-chip-val" id="chip-langs">{langs_with_tests}</div><div class="stat-chip-label">Languages with Tests</div><div class="stat-chip-tip">Number of distinct languages where test definitions were detected</div></div>
14148 <div class="stat-chip"><div class="stat-chip-val" id="chip-cov-pct">{cov_line_pct_str}%</div><div class="stat-chip-label">Line Coverage</div><div class="stat-chip-tip">Overall line coverage across all LCOV-instrumented files (empty if no LCOV data)</div></div>
14149 </div>
14150
14151 <div class="panel" id="viz-panel">
14152 <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">Visualizations</div>
14153
14154 <div class="chart-box" style="margin-bottom:18px;">
14155 <div class="chart-box-header">
14156 <div class="chart-box-title" style="margin-bottom:0;">Test Count Trend</div>
14157 <div style="display:flex;gap:8px;align-items:center;">
14158 <button class="chart-expand-btn" id="multi-compare-trend-btn" title="Open all scans in Multi-Scan Timeline" style="display:none;">⇌ Multi-Timeline</button>
14159 <button class="chart-expand-btn" id="trend-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
14160 </div>
14161 </div>
14162 <p style="font-size:13px;color:var(--muted);margin:0 0 10px;">Test metric trends across all saved scans for the selected scope. Use <strong>Multi-Timeline</strong> to compare scans side-by-side.</p>
14163 <div class="trend-controls-bar">
14164 <label>Y Metric:
14165 <select class="chart-select" id="tm-trend-y">
14166 <option value="test_count" selected>Test Definitions</option>
14167 <option value="code_lines">Code Lines</option>
14168 </select>
14169 </label>
14170 <label>X Axis:
14171 <select class="chart-select" id="tm-trend-x">
14172 <option value="commit" selected>By Commit</option>
14173 <option value="time">By Time</option>
14174 </select>
14175 </label>
14176 <label id="tm-sub-label" style="display:none;">Submodule:
14177 <select class="chart-select" id="tm-trend-sub">
14178 <option value="">All (project total)</option>
14179 </select>
14180 </label>
14181 <label>Chart Size:
14182 <select class="chart-select" id="tm-trend-size">
14183 <option value="200">Compact</option>
14184 <option value="260" selected>Normal</option>
14185 <option value="360">Large</option>
14186 </select>
14187 </label>
14188 </div>
14189 <div class="chart-canvas-wrap trend-canvas-wrap" id="trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
14190 <div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
14191 </div>
14192
14193 <div class="chart-row">
14194 <div class="chart-box">
14195 <div class="chart-box-header">
14196 <div class="chart-box-title" style="margin-bottom:0;">Test Definitions by Language</div>
14197 <button class="chart-expand-btn" id="tests-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
14198 </div>
14199 <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
14200 <div id="no-data-tests" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg><div class="chart-no-data-title">No test data</div><div class="chart-no-data-hint">Run a scan on a project with test files to see test definitions by language.</div></div>
14201 </div>
14202 <div class="chart-box">
14203 <div class="chart-box-header">
14204 <div class="chart-box-title" style="margin-bottom:0;">Test Density (per 1,000 code lines)</div>
14205 <button class="chart-expand-btn" id="density-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
14206 </div>
14207 <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
14208 <div id="no-data-density" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3v18h18"/><polyline points="7 16 11 11 15 14 19 8"/></svg><div class="chart-no-data-title">No density data</div><div class="chart-no-data-hint">Density requires detected test functions alongside code SLOC.</div></div>
14209 </div>
14210 </div>
14211
14212 <div class="chart-row">
14213 <div class="chart-box">
14214 <div class="chart-box-header">
14215 <div class="chart-box-title" style="margin-bottom:0;">Assertions by Language</div>
14216 <button class="chart-expand-btn" id="assertions-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
14217 </div>
14218 <div class="chart-canvas-wrap"><canvas id="canvas-assertions"></canvas></div>
14219 <div id="no-data-assertions" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="9"/><line x1="9" y1="12" x2="15" y2="12"/><line x1="12" y1="9" x2="12" y2="15"/></svg><div class="chart-no-data-title">No assertion data</div><div class="chart-no-data-hint">No assertion calls detected in the current scope.</div></div>
14220 </div>
14221 <div class="chart-box" id="suites-chart-box">
14222 <div class="chart-box-header">
14223 <div class="chart-box-title" style="margin-bottom:0;">Test Suites by Language</div>
14224 <button class="chart-expand-btn" id="suites-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
14225 </div>
14226 <div class="chart-canvas-wrap"><canvas id="canvas-suites"></canvas></div>
14227 <div id="no-data-suites" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg><div class="chart-no-data-title">No suite data</div><div class="chart-no-data-hint">No test suite groupings detected in the current scope.</div></div>
14228 </div>
14229 </div>
14230
14231 <div class="chart-row">
14232 <div class="chart-box">
14233 <div class="chart-box-title">Test Files Breakdown</div>
14234 <div class="chart-canvas-wrap" style="height:260px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-files"></canvas></div>
14235 <div id="no-data-files" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="9"/><path d="M12 8v4l3 3"/></svg><div class="chart-no-data-title">No file data</div><div class="chart-no-data-hint">No files found in the current scope.</div></div>
14236 </div>
14237 <div class="chart-box">
14238 <div class="chart-box-title">Test Composition</div>
14239 <p style="font-size:11px;color:var(--muted);margin:0 0 10px;">Total counts: test functions, assertions, and suites workspace-wide.</p>
14240 <div class="chart-canvas-wrap"><canvas id="canvas-composition"></canvas></div>
14241 <div id="no-data-composition" class="chart-no-data" style="display:none;"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg><div class="chart-no-data-title">No composition data</div><div class="chart-no-data-hint">Run a scan to see test function, assertion, and suite counts.</div></div>
14242 </div>
14243 </div>
14244 </div>
14245
14246 <div class="panel">
14247 <h1>Test Metrics</h1>
14248 <p class="muted">Lexical test definition counts across your codebase — how many test functions, test cases, and test decorators were detected per language, and how dense the test coverage is relative to production code.</p>
14249
14250 <div class="section-header">Language Breakdown</div>
14251 {cov_no_data_notice}
14252 <div style="overflow-x:auto;">
14253 <table class="data-table" id="lang-table">
14254 <thead><tr>
14255 <th>Language</th>
14256 <th class="num">Test Fns</th>
14257 <th class="num">Assertions</th>
14258 <th class="num">Suites</th>
14259 <th class="num">Code Lines</th>
14260 <th class="num">Files</th>
14261 <th class="num">Density / 1K</th>
14262 <th>Relative Density</th>
14263 </tr></thead>
14264 <tbody id="lang-tbody"></tbody>
14265 </table>
14266 </div>
14267 </div>
14268
14269 <div class="panel" id="cov-panel" style="display:none;">
14270 <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
14271 <div class="cov-gauge-row" id="cov-gauges">
14272 <div class="cov-gauge-card">
14273 <div class="cov-gauge-label">Line Coverage</div>
14274 <div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
14275 <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
14276 <div class="cov-gauge-sub">Lines hit / instrumented</div>
14277 <div class="cov-gauge-tip">Percentage of executable lines exercised by the test suite (lines hit ÷ lines instrumented), aggregated across every file in the LCOV report.</div>
14278 </div>
14279 <div class="cov-gauge-card">
14280 <div class="cov-gauge-label">Function Coverage</div>
14281 <div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
14282 <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
14283 <div class="cov-gauge-sub">Functions hit / found</div>
14284 <div class="cov-gauge-tip">Percentage of functions called at least once during testing (functions hit ÷ functions found). Shows 0% when the coverage report carries no function-level (FN/FNH) records.</div>
14285 </div>
14286 <div class="cov-gauge-card">
14287 <div class="cov-gauge-label">Branch Coverage</div>
14288 <div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
14289 <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
14290 <div class="cov-gauge-sub">Branches hit / found</div>
14291 <div class="cov-gauge-tip">Percentage of conditional branches taken during testing (branches hit ÷ branches found). Shows 0% when the coverage report carries no branch-level (BRDA/BRF) records.</div>
14292 </div>
14293 </div>
14294 <div class="chart-row">
14295 <div class="chart-box">
14296 <div class="chart-box-title">Line Coverage % by Language</div>
14297 <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
14298 </div>
14299 <div class="chart-box">
14300 <div class="chart-box-title">Coverage Tier Distribution</div>
14301 <div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
14302 </div>
14303 </div>
14304
14305 <div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
14306 <p class="muted" style="margin-bottom:14px;">Per-file line and function coverage from the LCOV report. Files are sorted from lowest to highest coverage. Use the filters to focus on gaps.</p>
14307 <div class="cov-file-toolbar">
14308 <div class="cov-filter-tabs" id="cov-filter-tabs">
14309 <button class="cov-tab active" data-tier="all">All</button>
14310 <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
14311 <button class="cov-tab" data-tier="low">Low (<50%)</button>
14312 <button class="cov-tab" data-tier="mid">Moderate (50–79%)</button>
14313 <button class="cov-tab" data-tier="high">High (≥80%)</button>
14314 </div>
14315 <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename…">
14316 </div>
14317 <div style="overflow-x:auto;">
14318 <table class="data-table" id="cov-file-table">
14319 <thead><tr>
14320 <th>File</th>
14321 <th>Lang</th>
14322 <th class="num">Line %</th>
14323 <th class="num">Lines Hit / Found</th>
14324 <th class="num">Fn %</th>
14325 <th class="num">Fns Hit / Found</th>
14326 </tr></thead>
14327 <tbody id="cov-file-tbody"></tbody>
14328 </table>
14329 </div>
14330 <div id="cov-file-empty" style="display:none;text-align:center;color:var(--muted);padding:24px;font-size:13px;">No files match the current filter.</div>
14331 <div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
14332 </div>
14333
14334 </div>
14335
14336 <footer class="site-footer">
14337 local code analysis - metrics, history and reports
14338 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Server</em>
14339 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
14340 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
14341 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
14342 · <a href="/api-docs" rel="noopener">REST API</a>
14343 </footer>
14344
14345 <script nonce="{nonce}">
14346 (function() {{
14347 // Theme
14348 var b = document.body;
14349 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
14350 var tgl = document.getElementById('theme-toggle');
14351 if (tgl) tgl.addEventListener('click', function() {{
14352 var d = b.classList.toggle('dark-theme');
14353 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
14354 }});
14355
14356 // Watermarks
14357 (function() {{
14358 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
14359 if (!wms.length) return;
14360 var placed = [];
14361 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;}}
14362 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];}}
14363 var half=Math.floor(wms.length/2);
14364 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;}});
14365 }})();
14366
14367 // Code particles
14368 (function() {{
14369 var container = document.getElementById('code-particles');
14370 if (!container) return;
14371 var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
14372 for (var i = 0; i < 36; i++) {{
14373 (function(idx) {{
14374 var el = document.createElement('span');
14375 el.className = 'code-particle';
14376 el.textContent = snippets[idx % snippets.length];
14377 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
14378 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
14379 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
14380 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';
14381 container.appendChild(el);
14382 }})(i);
14383 }}
14384 }})();
14385
14386 // Settings modal
14387 (function() {{
14388 var S=[{{n:'Classic',a:'#b85d33',b:'#7a371b'}},{{n:'Navy',a:'#283790',b:'#1e1e24'}},{{n:'Ember',a:'#ce5d3d',b:'#1e1e24'}},{{n:'Ocean',a:'#1f439b',b:'#1e1e24'}},{{n:'Royal',a:'#003184',b:'#1e1e24'}}];
14389 function ap(s){{document.documentElement.style.setProperty('--nav',s.a);document.documentElement.style.setProperty('--nav-2',s.b);try{{localStorage.setItem('sloc-ns',JSON.stringify(s));}}catch(e){{}}document.querySelectorAll('.scheme-swatch').forEach(function(x){{x.classList.toggle('active',x.dataset.n===s.n);}});}}
14390 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
14391 var btn=document.getElementById('settings-btn');if(!btn)return;
14392 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
14393 m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
14394 document.body.appendChild(m);
14395 var g=document.getElementById('scheme-grid');
14396 if(g)S.forEach(function(s){{var el=document.createElement('button');el.type='button';el.className='scheme-swatch';el.dataset.n=s.n;el.title=s.n;var p=document.createElement('div');p.className='scheme-preview';p.style.background='linear-gradient(135deg,'+s.a+','+s.b+')';var l=document.createElement('span');l.className='scheme-label';l.textContent=s.n;el.appendChild(p);el.appendChild(l);try{{var c=JSON.parse(localStorage.getItem('sloc-ns'));if(c&&c.n===s.n)el.classList.add('active');}}catch(e){{}}el.addEventListener('click',function(){{ap(s);}});g.appendChild(el);}});
14397 var cl=document.getElementById('settings-close');
14398 btn.addEventListener('click',function(e){{e.stopPropagation();var r=btn.getBoundingClientRect();m.style.top=(r.bottom+6)+'px';m.style.right=(window.innerWidth-r.right)+'px';m.classList.toggle('open');}});
14399 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
14400 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
14401 }})();
14402
14403 // Watched folder picker
14404 (function(){{
14405 window.__scanOverlay=function(msg){{var o=document.getElementById('scan-overlay');if(!o)return;if(o.parentNode!==document.body)document.body.appendChild(o);var t=o.querySelector('.scan-overlay-text');if(t&&msg)t.textContent=msg;o.classList.add('active');}};
14406 document.addEventListener('submit',function(e){{var f=e.target;if(!f||!f.getAttribute)return;var a=f.getAttribute('action')||'';if(a.indexOf('/watched-dirs/remove')!==-1){{window.__scanOverlay('Updating watched folders');}}else if(a.indexOf('/watched-dirs/')!==-1){{window.__scanOverlay();}}}},true);
14407 }})();
14408 (function() {{
14409 var btn = document.getElementById('add-watched-btn');
14410 if (!btn) return;
14411 btn.addEventListener('click', function() {{
14412 fetch('/pick-directory?kind=reports')
14413 .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
14414 .then(function(data) {{
14415 if (!data.cancelled && data.selected_path) {{
14416 var form = document.createElement('form');
14417 form.method = 'POST';
14418 form.action = '/watched-dirs/add';
14419 var ri = document.createElement('input');
14420 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
14421 var fi = document.createElement('input');
14422 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
14423 form.appendChild(ri); form.appendChild(fi);
14424 document.body.appendChild(form);
14425 if (window.__scanOverlay) window.__scanOverlay();
14426 form.submit();
14427 }}
14428 }})
14429 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
14430 }});
14431 }})();
14432 }})();
14433 </script>
14434
14435 <script src="/static/chart.js" nonce="{nonce}"></script>
14436 <script nonce="{nonce}">
14437 (function() {{
14438 var SCOPE_DATA = {scope_data_json};
14439 var currentRoot = '__all__';
14440 var currentSub = '';
14441 var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
14442 var assertionsChart = null, suitesChart = null, filesChart = null, compositionChart = null;
14443 var ALL_CHARTS = [];
14444 var currentLangTests = [];
14445 var currentTrendPts = [];
14446
14447 function fmt(n){{var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}}
14448 function fmtFull(n){{return Number(n).toLocaleString();}}
14449 function isDark(){{return document.body.classList.contains('dark-theme');}}
14450 function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
14451 function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
14452 var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
14453
14454 function makeDlPlugin(fmtFn, anchor) {{
14455 return {{
14456 afterDatasetsDraw: function(chart) {{
14457 var ctx = chart.ctx;
14458 var tc = txtClr();
14459 chart.data.datasets.forEach(function(ds, di) {{
14460 var meta = chart.getDatasetMeta(di);
14461 meta.data.forEach(function(el, idx) {{
14462 var label = fmtFn(ds.data[idx], di, idx);
14463 if (label == null || label === '') return;
14464 ctx.save();
14465 ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
14466 ctx.fillStyle = tc;
14467 if (anchor === 'top') {{
14468 ctx.textAlign = 'center';
14469 ctx.textBaseline = 'bottom';
14470 ctx.fillText(String(label), el.x, el.y - 5);
14471 }} else {{
14472 ctx.textAlign = 'left';
14473 ctx.textBaseline = 'middle';
14474 ctx.fillText(String(label), el.x + 5, el.y);
14475 }}
14476 ctx.restore();
14477 }});
14478 }});
14479 }}
14480 }};
14481 }}
14482
14483 // Cursor: pointer over chart data, default over empty chart area.
14484 function chartCursor(e, els) {{
14485 var t = e.native && e.native.target;
14486 if (t) t.style.cursor = els.length ? 'pointer' : 'default';
14487 }}
14488 Chart.defaults.onHover = chartCursor; // applies to every chart on this page
14489
14490 // ── Global bar hover emphasis ──────────────────────────────────────────────
14491 // Doughnuts pop via hoverOffset; bars had no per-bar hover feedback (fading the
14492 // *other* bars does nothing when there is only one). Give every bar chart a
14493 // built-in "pop": the hovered bar brightens, lifts with a rounded outline, and
14494 // animates via the fast active transition. Applied globally through a plugin so
14495 // it covers all current and future bar charts on the page.
14496 function tmLighten(c, amt) {{
14497 if (typeof c === 'string' && c.charAt(0) === '#' && c.length === 7) {{
14498 var n = parseInt(c.slice(1), 16), r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
14499 r = Math.round(r + (255 - r) * amt);
14500 g = Math.round(g + (255 - g) * amt);
14501 b = Math.round(b + (255 - b) * amt);
14502 return 'rgb(' + r + ',' + g + ',' + b + ')';
14503 }}
14504 return c;
14505 }}
14506 var tmBarHoverEmphasis = {{
14507 id: 'tmBarHoverEmphasis',
14508 beforeInit: function(chart) {{
14509 if (!chart.config || chart.config.type !== 'bar') return;
14510 (chart.data.datasets || []).forEach(function(ds) {{
14511 var bg = ds.backgroundColor;
14512 if (ds.hoverBackgroundColor == null) {{
14513 ds.hoverBackgroundColor = Array.isArray(bg)
14514 ? bg.map(function(c) {{ return tmLighten(c, 0.24); }})
14515 : tmLighten(bg, 0.24);
14516 }}
14517 if (ds.hoverBorderColor == null) {{
14518 ds.hoverBorderColor = isDark() ? 'rgba(245,236,230,0.9)' : 'rgba(67,52,45,0.82)';
14519 }}
14520 if (ds.hoverBorderWidth == null) ds.hoverBorderWidth = 3;
14521 }});
14522 }}
14523 }};
14524 Chart.register(tmBarHoverEmphasis);
14525 // Quick, smooth tween when a bar enters/leaves the hovered (active) state.
14526 try {{
14527 Chart.defaults.transitions.active = Chart.defaults.transitions.active || {{}};
14528 Chart.defaults.transitions.active.animation = Chart.defaults.transitions.active.animation || {{}};
14529 Chart.defaults.transitions.active.animation.duration = 260;
14530 }} catch (e) {{}}
14531
14532 // Plugin: draws % labels inside each doughnut slice.
14533 var donutPctPlugin = {{
14534 afterDatasetsDraw: function(chart) {{
14535 var ctx = chart.ctx;
14536 chart.data.datasets.forEach(function(ds, di) {{
14537 var meta = chart.getDatasetMeta(di);
14538 if (meta.hidden) return;
14539 var total = 0;
14540 for (var k = 0; k < ds.data.length; k++) total += (ds.data[k] || 0);
14541 if (!total) return;
14542 meta.data.forEach(function(arc, i) {{
14543 if (arc.hidden) return;
14544 var val = ds.data[i] || 0;
14545 var pct = val / total * 100;
14546 if (pct < 3) return;
14547 var midAngle = (arc.startAngle + arc.endAngle) / 2;
14548 var midR = (arc.innerRadius + arc.outerRadius) / 2;
14549 var tx = arc.x + midR * Math.cos(midAngle);
14550 var ty = arc.y + midR * Math.sin(midAngle);
14551 ctx.save();
14552 ctx.textAlign = 'center';
14553 ctx.textBaseline = 'middle';
14554 ctx.font = 'bold 13px Inter,ui-sans-serif,sans-serif';
14555 ctx.shadowColor = 'rgba(0,0,0,0.45)';
14556 ctx.shadowBlur = 3;
14557 ctx.fillStyle = '#fff';
14558 ctx.fillText(pct.toFixed(0) + '%', tx, ty);
14559 ctx.restore();
14560 }});
14561 }});
14562 }}
14563 }};
14564
14565 function makeTmOverlay(title, subtitle, h) {{
14566 var overlay = document.createElement('div');
14567 overlay.className = 'chart-modal-overlay';
14568 var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
14569 var ch = Math.min(h || 560, maxH);
14570 var subHtml = subtitle ? '<span class="chart-modal-subtitle">' + subtitle + '</span>' : '';
14571 overlay.innerHTML = '<div class="chart-modal" style="max-width:1200px;"><button class="chart-modal-close" aria-label="Close">×</button><span class="chart-modal-title">' + title + '</span>' + subHtml + '<div style="position:relative;width:100%;height:' + ch + 'px;"><canvas id="tm-modal-canvas"></canvas></div></div>';
14572 document.body.appendChild(overlay);
14573 overlay.querySelector('.chart-modal-close').addEventListener('click', function(){{ document.body.removeChild(overlay); }});
14574 overlay.addEventListener('click', function(e){{ if (e.target === overlay) document.body.removeChild(overlay); }});
14575 return document.getElementById('tm-modal-canvas');
14576 }}
14577
14578 function getDataset() {{
14579 var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
14580 if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
14581 return r;
14582 }}
14583 function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
14584
14585 function showNoData(id, show) {{
14586 var el = document.getElementById(id);
14587 if (!el) return;
14588 var wrap = el.previousElementSibling;
14589 el.style.display = show ? '' : 'none';
14590 if (wrap && wrap.classList.contains('chart-canvas-wrap')) wrap.style.display = show ? 'none' : '';
14591 }}
14592
14593 // Shared hover treatment for every single-series bar/doughnut chart on this page:
14594 // emphasise the hovered bar/arc and fade the rest, mirroring the highlight+fade
14595 // treatment used by the language charts on the scan results page.
14596 function tmFadeColor(c) {{
14597 if (typeof c === 'string' && c.charAt(0) === '#' && c.length === 7) return c + '3D';
14598 return c;
14599 }}
14600 function tmApplyFade(chart, activeIdx) {{
14601 var ds = chart.data.datasets[0];
14602 if (!ds._baseBg) ds._baseBg = ds.backgroundColor.slice();
14603 if (activeIdx == null) {{
14604 ds.backgroundColor = ds._baseBg.slice();
14605 }} else {{
14606 ds.backgroundColor = ds._baseBg.map(function(c, i) {{
14607 return i === activeIdx ? ds._baseBg[i] : tmFadeColor(ds._baseBg[i]);
14608 }});
14609 }}
14610 }}
14611 function tmFadeHover(e, active, chart) {{
14612 var t = e.native && e.native.target;
14613 if (t) t.style.cursor = active.length ? 'pointer' : 'default';
14614 var idx = active.length ? active[0].index : null;
14615 if (chart._fadeIdx === idx) return;
14616 chart._fadeIdx = idx;
14617 tmApplyFade(chart, idx);
14618 // 'active' mode tweens the fade + the hovered bar's pop via the fast active
14619 // transition (doughnuts keep their own hoverOffset motion regardless).
14620 chart.update('active');
14621 }}
14622 // Legend hover on a doughnut should highlight+fade exactly like hovering the arc.
14623 function tmDoughnutLegendHover(e, item, leg) {{
14624 var ch = leg.chart;
14625 var t = e.native && e.native.target;
14626 if (t) t.style.cursor = 'pointer';
14627 ch._fadeIdx = item.index;
14628 ch.setActiveElements([{{ datasetIndex: 0, index: item.index }}]);
14629 ch.tooltip.setActiveElements([{{ datasetIndex: 0, index: item.index }}], {{ x: 0, y: 0 }});
14630 tmApplyFade(ch, item.index);
14631 ch.update();
14632 }}
14633 function tmDoughnutLegendLeave(e, item, leg) {{
14634 var ch = leg.chart;
14635 var t = e.native && e.native.target;
14636 if (t) t.style.cursor = 'default';
14637 ch._fadeIdx = null;
14638 ch.setActiveElements([]);
14639 ch.tooltip.setActiveElements([], {{}});
14640 tmApplyFade(ch, null);
14641 ch.update('none');
14642 }}
14643
14644 function renderTestCharts(D) {{
14645 currentLangTests = D || [];
14646 testsChart = destroyChart(testsChart);
14647 densityChart = destroyChart(densityChart);
14648 if (!D || !D.length) {{
14649 showNoData('no-data-tests', true);
14650 showNoData('no-data-density', true);
14651 return;
14652 }}
14653 showNoData('no-data-tests', false);
14654 showNoData('no-data-density', false);
14655 var top15 = D.slice(0, 15);
14656 var canvas1 = document.getElementById('canvas-tests');
14657 if (canvas1) {{
14658 testsChart = new Chart(canvas1, {{
14659 type: 'bar',
14660 data: {{
14661 labels: top15.map(function(d){{ return d.lang; }}),
14662 datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
14663 }},
14664 options: {{
14665 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
14666 layout: {{ padding: {{ right: 64 }} }},
14667 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14668 scales: {{
14669 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14670 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14671 }}
14672 }},
14673 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14674 }});
14675 ALL_CHARTS.push(testsChart);
14676 }}
14677 var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
14678 var canvas2 = document.getElementById('canvas-density');
14679 if (canvas2) {{
14680 densityChart = new Chart(canvas2, {{
14681 type: 'bar',
14682 data: {{
14683 labels: topD.map(function(d){{ return d.lang; }}),
14684 datasets: [{{ label: 'Tests / 1K Code Lines', data: topD.map(function(d){{ return d.density; }}), backgroundColor: topD.map(function(_,i){{ return PALETTE[(i+4) % PALETTE.length]; }}), borderRadius: 4 }}]
14685 }},
14686 options: {{
14687 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
14688 layout: {{ padding: {{ right: 64 }} }},
14689 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
14690 scales: {{
14691 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
14692 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14693 }}
14694 }},
14695 plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
14696 }});
14697 ALL_CHARTS.push(densityChart);
14698 }}
14699 }}
14700
14701 function renderAssertionsChart(D) {{
14702 assertionsChart = destroyChart(assertionsChart);
14703 if (!D || !D.length) {{ showNoData('no-data-assertions', true); return; }}
14704 var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
14705 var canvas = document.getElementById('canvas-assertions');
14706 if (!canvas || !top15.length) {{ showNoData('no-data-assertions', true); return; }}
14707 showNoData('no-data-assertions', false);
14708 assertionsChart = new Chart(canvas, {{
14709 type: 'bar',
14710 data: {{
14711 labels: top15.map(function(d){{ return d.lang; }}),
14712 datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
14713 }},
14714 options: {{
14715 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
14716 layout: {{ padding: {{ right: 64 }} }},
14717 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14718 scales: {{
14719 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14720 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14721 }}
14722 }},
14723 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14724 }});
14725 ALL_CHARTS.push(assertionsChart);
14726 }}
14727
14728 function renderSuitesChart(D) {{
14729 suitesChart = destroyChart(suitesChart);
14730 if (!D || !D.length) {{ showNoData('no-data-suites', true); return; }}
14731 var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
14732 var canvas = document.getElementById('canvas-suites');
14733 if (!canvas || !top15.length) {{ showNoData('no-data-suites', true); return; }}
14734 showNoData('no-data-suites', false);
14735 suitesChart = new Chart(canvas, {{
14736 type: 'bar',
14737 data: {{
14738 labels: top15.map(function(d){{ return d.lang; }}),
14739 datasets: [{{ label: 'Test Suites', data: top15.map(function(d){{ return d.suites; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+6) % PALETTE.length]; }}), borderRadius: 4 }}]
14740 }},
14741 options: {{
14742 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
14743 layout: {{ padding: {{ right: 64 }} }},
14744 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14745 scales: {{
14746 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14747 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14748 }}
14749 }},
14750 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14751 }});
14752 ALL_CHARTS.push(suitesChart);
14753 }}
14754
14755 function renderFilesChart(totals) {{
14756 filesChart = destroyChart(filesChart);
14757 var canvas = document.getElementById('canvas-files');
14758 if (!canvas) return;
14759 var testF = totals.test_files || 0;
14760 var totalF = totals.total_files || 0;
14761 var nonTest = Math.max(0, totalF - testF);
14762 if (totalF === 0) {{ showNoData('no-data-files', true); return; }}
14763 showNoData('no-data-files', false);
14764 var dark = isDark();
14765 filesChart = new Chart(canvas, {{
14766 type: 'doughnut',
14767 data: {{
14768 labels: ['Test Files', 'Non-Test Files'],
14769 datasets: [{{ data: [testF, nonTest], backgroundColor: ['#C45C10', dark ? '#524238' : '#e6d0bf'], borderWidth: 2, borderColor: dark ? '#1e1e1e' : '#f5efe8', hoverOffset: 14 }}]
14770 }},
14771 options: {{
14772 responsive: true, maintainAspectRatio: false, cutout: '62%',
14773 onHover: tmFadeHover,
14774 plugins: {{
14775 legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16,
14776 generateLabels: function(chart) {{
14777 var ds = chart.data.datasets[0];
14778 var tot = ds.data.reduce(function(a,b){{return a+(b||0);}}, 0);
14779 return chart.data.labels.map(function(lbl, i) {{
14780 var val = ds.data[i] || 0;
14781 var pct = tot > 0 ? (val / tot * 100).toFixed(0) : '0';
14782 return {{
14783 text: lbl + ' ' + fmtFull(val) + ' (' + pct + '%)',
14784 fillStyle: ds.backgroundColor[i],
14785 strokeStyle: ds.borderColor,
14786 lineWidth: ds.borderWidth,
14787 hidden: false,
14788 index: i,
14789 datasetIndex: 0
14790 }};
14791 }});
14792 }}
14793 }},
14794 onHover: tmDoughnutLegendHover,
14795 onLeave: tmDoughnutLegendLeave
14796 }},
14797 tooltip: {{ callbacks: {{ label: function(ctx) {{
14798 var v = ctx.parsed, pct = totalF > 0 ? (v / totalF * 100).toFixed(1) : '0';
14799 return ' ' + fmtFull(v) + ' files (' + pct + '%)';
14800 }} }} }}
14801 }}
14802 }},
14803 plugins: [donutPctPlugin]
14804 }});
14805 ALL_CHARTS.push(filesChart);
14806 }}
14807
14808 function renderCompositionChart(totals) {{
14809 compositionChart = destroyChart(compositionChart);
14810 var canvas = document.getElementById('canvas-composition');
14811 if (!canvas) return;
14812 var tc = totals.test_count || 0, ac = totals.assertions || 0, sc = totals.suites || 0;
14813 if (tc === 0 && ac === 0 && sc === 0) {{ showNoData('no-data-composition', true); return; }}
14814 showNoData('no-data-composition', false);
14815 compositionChart = new Chart(canvas, {{
14816 type: 'bar',
14817 data: {{
14818 labels: ['Test Functions', 'Assertions', 'Test Suites'],
14819 datasets: [{{ label: 'Count', data: [tc, ac, sc], backgroundColor: ['#C45C10', '#2A6846', '#4472C4'], borderRadius: 6 }}]
14820 }},
14821 options: {{
14822 responsive: true, maintainAspectRatio: false,
14823 onHover: tmFadeHover,
14824 layout: {{ padding: {{ top: 22 }} }},
14825 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y); }} }} }} }},
14826 scales: {{
14827 x: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }},
14828 y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
14829 }}
14830 }},
14831 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top')]
14832 }});
14833 ALL_CHARTS.push(compositionChart);
14834 }}
14835
14836 function renderCovCharts(covD, tiers) {{
14837 covChart = destroyChart(covChart);
14838 tierChart = destroyChart(tierChart);
14839 var covCanvas = document.getElementById('canvas-cov');
14840 if (covCanvas && covD && covD.length) {{
14841 covChart = new Chart(covCanvas, {{
14842 type: 'bar',
14843 data: {{
14844 labels: covD.map(function(d){{ return d.lang; }}),
14845 datasets: [{{ label: 'Line Coverage %', data: covD.map(function(d){{ return d.pct; }}), backgroundColor: covD.map(function(d){{ return d.pct >= 80 ? '#2A6846' : d.pct >= 50 ? '#D4A017' : '#B23030'; }}), borderRadius: 4 }}]
14846 }},
14847 options: {{
14848 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
14849 layout: {{ padding: {{ right: 52 }} }},
14850 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
14851 scales: {{
14852 x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
14853 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14854 }}
14855 }},
14856 plugins: [makeDlPlugin(function(v){{ return Number(v).toFixed(1) + '%'; }}, 'end')]
14857 }});
14858 ALL_CHARTS.push(covChart);
14859 }}
14860 var tierCanvas = document.getElementById('canvas-cov-tiers');
14861 if (tierCanvas && tiers) {{
14862 var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
14863 tierChart = new Chart(tierCanvas, {{
14864 type: 'doughnut',
14865 data: {{
14866 labels: ['High (\u226580%)', 'Moderate (50\u201379%)', 'Low (<50%)'],
14867 datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8', hoverOffset: 14 }}]
14868 }},
14869 options: {{
14870 responsive: true, maintainAspectRatio: false, cutout: '62%',
14871 onHover: tmFadeHover,
14872 plugins: {{
14873 legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 14 }},
14874 onHover: tmDoughnutLegendHover,
14875 onLeave: tmDoughnutLegendLeave
14876 }},
14877 tooltip: {{ callbacks: {{ label: function(ctx) {{
14878 var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
14879 return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
14880 }} }} }}
14881 }}
14882 }},
14883 plugins: [donutPctPlugin]
14884 }});
14885 ALL_CHARTS.push(tierChart);
14886 }}
14887 }}
14888
14889 function buildLangTable(D) {{
14890 var tbody = document.getElementById('lang-tbody');
14891 if (!tbody) return;
14892 if (!D || !D.length) {{
14893 tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:var(--muted);padding:24px;">No test definitions detected. Run a scan on a project with test files.</td></tr>';
14894 return;
14895 }}
14896 var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
14897 tbody.innerHTML = D.map(function(d) {{
14898 var barW = Math.round(d.density / maxDensity * 120);
14899 return '<tr>' +
14900 '<td><strong>' + d.lang + '</strong></td>' +
14901 '<td class="num">' + fmtFull(d.tests) + '</td>' +
14902 '<td class="num">' + fmtFull(d.assertions || 0) + '</td>' +
14903 '<td class="num">' + fmtFull(d.suites || 0) + '</td>' +
14904 '<td class="num">' + fmtFull(d.code) + '</td>' +
14905 '<td class="num">' + fmtFull(d.files) + '</td>' +
14906 '<td class="num">' + d.density.toFixed(2) + '</td>' +
14907 '<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
14908 '</tr>';
14909 }}).join('');
14910 }}
14911
14912 var covFileData = [];
14913 var covFileTier = 'all';
14914 var covFileSearch = '';
14915
14916 function pctBadge(pct) {{
14917 var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
14918 var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
14919 return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
14920 }}
14921
14922 function buildCovFileTable() {{
14923 var tbody = document.getElementById('cov-file-tbody');
14924 var empty = document.getElementById('cov-file-empty');
14925 var count = document.getElementById('cov-file-count');
14926 if (!tbody) return;
14927 var srch = covFileSearch.toLowerCase();
14928 var filtered = covFileData.filter(function(f) {{
14929 if (covFileTier === 'zero' && f.line_pct > 0) return false;
14930 if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
14931 if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
14932 if (covFileTier === 'high' && f.line_pct < 80) return false;
14933 if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
14934 return true;
14935 }});
14936 if (!filtered.length) {{
14937 tbody.innerHTML = '';
14938 if (empty) empty.style.display = '';
14939 if (count) count.textContent = '';
14940 return;
14941 }}
14942 if (empty) empty.style.display = 'none';
14943 var shown = Math.min(filtered.length, 500);
14944 if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
14945 tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
14946 var fnCol = f.fn_pct < 0
14947 ? '<td class="num" style="color:var(--muted);font-size:11px;">\u2014</td><td class="num" style="color:var(--muted);font-size:11px;">\u2014</td>'
14948 : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
14949 return '<tr>' +
14950 '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '"') + '">' + f.rel + '</td>' +
14951 '<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
14952 '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
14953 '<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
14954 fnCol +
14955 '</tr>';
14956 }}).join('');
14957 }}
14958
14959 (function() {{
14960 var tabs = document.getElementById('cov-filter-tabs');
14961 if (tabs) {{
14962 tabs.addEventListener('click', function(e) {{
14963 var btn = e.target.closest('.cov-tab');
14964 if (!btn) return;
14965 Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
14966 btn.classList.add('active');
14967 covFileTier = btn.getAttribute('data-tier');
14968 buildCovFileTable();
14969 }});
14970 }}
14971 var srch = document.getElementById('cov-file-search');
14972 if (srch) {{
14973 srch.addEventListener('input', function() {{
14974 covFileSearch = this.value;
14975 buildCovFileTable();
14976 }});
14977 }}
14978 }})();
14979
14980 function updateCovGauges(t) {{
14981 var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
14982 var el;
14983 if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
14984 if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
14985 if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
14986 if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
14987 if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
14988 if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
14989 }}
14990
14991 function applyScope() {{
14992 var d = getDataset();
14993 var t = d.totals;
14994 var el;
14995 if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
14996 if ((el = document.getElementById('chip-total-exact'))) el.textContent = fmtFull(t.test_count);
14997 if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
14998 if ((el = document.getElementById('chip-assertions-exact'))) el.textContent = fmtFull(t.assertions);
14999 if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
15000 if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
15001 if ((el = document.getElementById('chip-test-files-exact'))) el.textContent = fmtFull(t.test_files) + ' / ' + fmtFull(t.total_files);
15002 if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
15003 if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
15004 if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
15005 if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
15006 renderTestCharts(d.lang_tests);
15007 renderAssertionsChart(d.lang_tests);
15008 renderSuitesChart(d.lang_tests);
15009 renderFilesChart(t);
15010 renderCompositionChart(t);
15011 buildLangTable(d.lang_tests);
15012 var covPanel = document.getElementById('cov-panel');
15013 if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
15014 if (d.has_coverage) {{
15015 renderCovCharts(d.cov, d.cov_tiers);
15016 updateCovGauges(t);
15017 covFileData = d.file_cov || [];
15018 covFileTier = 'all';
15019 covFileSearch = '';
15020 var tabs = document.getElementById('cov-filter-tabs');
15021 if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
15022 var srch = document.getElementById('cov-file-search');
15023 if (srch) srch.value = '';
15024 buildCovFileTable();
15025 }}
15026 loadTrend();
15027 }}
15028
15029 // Populate scope-root-sel from SCOPE_DATA keys
15030 (function() {{
15031 var sel = document.getElementById('scope-root-sel');
15032 if (!sel) return;
15033 Object.keys(SCOPE_DATA).forEach(function(k) {{
15034 if (k === '__all__') return;
15035 var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
15036 }});
15037 }})();
15038
15039 document.getElementById('scope-root-sel').addEventListener('change', function() {{
15040 currentRoot = this.value;
15041 currentSub = '';
15042 var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
15043 var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
15044 var subWrap = document.getElementById('scope-sub-wrap');
15045 var subSel = document.getElementById('scope-sub-sel');
15046 subSel.innerHTML = '<option value="">Entire project</option>';
15047 if (subNames.length) {{
15048 subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
15049 subWrap.style.display = 'flex';
15050 }} else {{
15051 subWrap.style.display = 'none';
15052 }}
15053 applyScope();
15054 }});
15055
15056 document.getElementById('scope-sub-sel').addEventListener('change', function() {{
15057 currentSub = this.value;
15058 applyScope();
15059 }});
15060
15061 var allTrendData = [];
15062
15063 var TM_Y_META = {{
15064 test_count: {{ label: 'Test Definitions', color: '#C45C10', tooltip: ' test defs' }},
15065 code_lines: {{ label: 'Code Lines', color: '#2A6846', tooltip: ' code lines' }}
15066 }};
15067
15068 // Parse a hex color (#RRGGBB) into "r,g,b" for building rgba() gradient stops.
15069 function hexRgb(hex) {{
15070 var h = String(hex).replace('#', '');
15071 if (h.length === 3) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2];
15072 var n = parseInt(h, 16);
15073 return ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255);
15074 }}
15075 // Vertical area-fill gradient matching the inline trend chart: fades from a soft
15076 // tint at the top to transparent at the bottom (no flat solid block).
15077 function tmTrendGradient(ctx2, chartArea, color) {{
15078 var rgb = hexRgb(color);
15079 var g = ctx2.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
15080 g.addColorStop(0, 'rgba(' + rgb + ',0.28)');
15081 g.addColorStop(0.5, 'rgba(' + rgb + ',0.10)');
15082 g.addColorStop(1, 'rgba(' + rgb + ',0)');
15083 return g;
15084 }}
15085
15086 // Pixel Y of the trend line at canvas-space x (tension 0 → straight segments,
15087 // so linear interpolation between adjacent points matches the drawn line).
15088 function tmLineYAt(chart, px) {{
15089 var meta = chart.getDatasetMeta(0);
15090 if (!meta || !meta.data || !meta.data.length) return null;
15091 var d = meta.data;
15092 if (px <= d[0].x) return d[0].y;
15093 for (var i = 1; i < d.length; i++) {{
15094 if (px <= d[i].x) {{
15095 var span = d[i].x - d[i - 1].x;
15096 var t = span > 0 ? (px - d[i - 1].x) / span : 0;
15097 return d[i - 1].y + t * (d[i].y - d[i - 1].y);
15098 }}
15099 }}
15100 return d[d.length - 1].y;
15101 }}
15102
15103 // Plugin: only show the tooltip / finger cursor when the pointer is over the
15104 // gradient fill (inside the plot and at/below the line) — never in the empty
15105 // space above the line. Outside the fill we retype the event as 'mouseout' so
15106 // the core interaction dismisses any active tooltip on its own.
15107 var tmFillGuard = {{
15108 id: 'tmFillGuard',
15109 beforeEvent: function(chart, args) {{
15110 var e = args.event;
15111 if (!e || e.type !== 'mousemove') return;
15112 var ca = chart.chartArea;
15113 if (!ca) return;
15114 var inFill = false;
15115 if (e.x >= ca.left && e.x <= ca.right) {{
15116 var ly = tmLineYAt(chart, e.x);
15117 if (ly != null && e.y >= ly - 6 && e.y <= ca.bottom) inFill = true;
15118 }}
15119 if (chart.canvas) chart.canvas.style.cursor = inFill ? 'pointer' : 'default';
15120 if (!inFill) {{ e.type = 'mouseout'; }}
15121 }}
15122 }};
15123
15124 // Single source of truth for the test-metrics trend chart config so the inline
15125 // chart and the Full View modal render identically (straight segments, gradient
15126 // fill, white-ringed points, gradient-only interactivity).
15127 function buildTmTrendConfig(pts, ctrl, meta) {{
15128 return {{
15129 type: 'line',
15130 data: {{
15131 labels: pts.map(function(d){{ return makeTrendLabel(d, ctrl.xMode); }}),
15132 datasets: [{{
15133 label: meta.label,
15134 data: pts.map(function(d){{ return Number(d[ctrl.yKey]) || 0; }}),
15135 borderColor: meta.color,
15136 borderWidth: 2.5,
15137 backgroundColor: function(context) {{
15138 var ca = context.chart.chartArea;
15139 if (!ca) return 'rgba(' + hexRgb(meta.color) + ',0.15)';
15140 return tmTrendGradient(context.chart.ctx, ca, meta.color);
15141 }},
15142 pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : meta.color; }}),
15143 pointBorderColor: '#fff',
15144 pointBorderWidth: 2,
15145 pointRadius: 6,
15146 pointHoverRadius: 9,
15147 pointHoverBorderWidth: 2.5,
15148 fill: true, tension: 0
15149 }}]
15150 }},
15151 options: {{
15152 responsive: true, maintainAspectRatio: false,
15153 layout: {{ padding: {{ top: 22 }} }},
15154 interaction: {{ mode: 'index', intersect: false }},
15155 plugins: {{
15156 legend: {{ display: false }},
15157 tooltip: {{
15158 mode: 'index', intersect: false,
15159 callbacks: {{ label: function(ctx2){{ return ' ' + fmtFull(ctx2.parsed.y) + meta.tooltip; }} }}
15160 }}
15161 }},
15162 scales: {{
15163 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, maxRotation:35 }} }},
15164 y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
15165 }}
15166 }},
15167 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top'), tmFillGuard]
15168 }};
15169 }}
15170
15171 function getTrendControls() {{
15172 var ySel = document.getElementById('tm-trend-y');
15173 var xSel = document.getElementById('tm-trend-x');
15174 var sizeSel = document.getElementById('tm-trend-size');
15175 var subSel = document.getElementById('tm-trend-sub');
15176 return {{
15177 yKey: ySel ? ySel.value : 'test_count',
15178 xMode: xSel ? xSel.value : 'commit',
15179 height: sizeSel ? parseInt(sizeSel.value, 10) : 260,
15180 submod: subSel ? subSel.value : ''
15181 }};
15182 }}
15183
15184 function makeTrendLabel(d, xMode) {{
15185 if (xMode === 'commit') {{
15186 return d.commit ? d.commit.substring(0, 7) : (d.run_id_short || '?');
15187 }}
15188 return d.timestamp ? d.timestamp.slice(0, 10) : d.run_id_short;
15189 }}
15190
15191 function buildTrend(data) {{
15192 allTrendData = data || [];
15193 renderTrend();
15194 }}
15195
15196 function renderTrend() {{
15197 var data = allTrendData;
15198 var ctrl = getTrendControls();
15199 var trendCanvas = document.getElementById('canvas-trend');
15200 var trendWrap = document.getElementById('trend-canvas-wrap');
15201 var trendEmpty = document.getElementById('trend-empty');
15202
15203 // Apply chart size
15204 if (trendWrap) trendWrap.style.height = ctrl.height + 'px';
15205
15206 // Filter by submodule if selected (entries from project_label match)
15207 var pts = data.slice().reverse();
15208 if (ctrl.submod) {{
15209 pts = pts.filter(function(d) {{ return d.project_label === ctrl.submod; }});
15210 }}
15211
15212 currentTrendPts = pts;
15213
15214 if (!pts.length) {{
15215 if (trendCanvas) trendCanvas.style.display = 'none';
15216 if (trendEmpty) trendEmpty.style.display = '';
15217 return;
15218 }}
15219 if (trendCanvas) trendCanvas.style.display = '';
15220 if (trendEmpty) trendEmpty.style.display = 'none';
15221
15222 trendChart = destroyChart(trendChart);
15223 if (!trendCanvas) return;
15224
15225 var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
15226
15227 trendChart = new Chart(trendCanvas, buildTmTrendConfig(pts, ctrl, meta));
15228 trendCanvas.addEventListener('mouseleave', function() {{ trendCanvas.style.cursor = 'default'; }});
15229 ALL_CHARTS.push(trendChart);
15230
15231 // Populate submodule selector from unique project_labels
15232 var subSel = document.getElementById('tm-trend-sub');
15233 var subLabel = document.getElementById('tm-sub-label');
15234 if (subSel && data.length) {{
15235 var projects = [];
15236 data.forEach(function(d) {{ if (d.project_label && projects.indexOf(d.project_label) < 0) projects.push(d.project_label); }});
15237 if (projects.length > 1) {{
15238 var curVal = subSel.value;
15239 subSel.innerHTML = '<option value="">All (project total)</option>';
15240 projects.forEach(function(p) {{ subSel.innerHTML += '<option value="'+p.replace(/"/g,'"')+'"'+(p===curVal?' selected':'')+'>'+p+'</option>'; }});
15241 if (subLabel) subLabel.style.display = '';
15242 }} else {{
15243 if (subLabel) subLabel.style.display = 'none';
15244 }}
15245 }}
15246 }}
15247
15248 // ── Full View expand buttons ──────────────────────────────────────────────
15249 (function() {{
15250 var btn = document.getElementById('tests-expand-btn');
15251 if (!btn) return;
15252 btn.addEventListener('click', function() {{
15253 var D = currentLangTests;
15254 if (!D || !D.length) return;
15255 var top15 = D.slice(0, 15);
15256 var h = Math.max(320, top15.length * 36 + 80);
15257 var canvas = makeTmOverlay('Test Definitions by Language \u2014 Full View', top15.length + ' languages', h);
15258 if (!canvas) return;
15259 new Chart(canvas, {{
15260 type: 'bar',
15261 data: {{
15262 labels: top15.map(function(d){{ return d.lang; }}),
15263 datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
15264 }},
15265 options: {{
15266 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15267 layout: {{ padding: {{ right: 72 }} }},
15268 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15269 scales: {{
15270 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
15271 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15272 }}
15273 }},
15274 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15275 }});
15276 }});
15277 }})();
15278
15279 (function() {{
15280 var btn = document.getElementById('density-expand-btn');
15281 if (!btn) return;
15282 btn.addEventListener('click', function() {{
15283 var D = currentLangTests;
15284 if (!D || !D.length) return;
15285 var topD = D.slice().sort(function(a,b){{ return b.density - a.density; }}).slice(0, 15);
15286 var h = Math.max(320, topD.length * 36 + 80);
15287 var canvas = makeTmOverlay('Test Density (per 1,000 code lines) \u2014 Full View', topD.length + ' languages', h);
15288 if (!canvas) return;
15289 new Chart(canvas, {{
15290 type: 'bar',
15291 data: {{
15292 labels: topD.map(function(d){{ return d.lang; }}),
15293 datasets: [{{ label: 'Tests / 1K Code Lines', data: topD.map(function(d){{ return d.density; }}), backgroundColor: topD.map(function(_,i){{ return PALETTE[(i+4) % PALETTE.length]; }}), borderRadius: 4 }}]
15294 }},
15295 options: {{
15296 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15297 layout: {{ padding: {{ right: 72 }} }},
15298 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
15299 scales: {{
15300 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return v.toFixed(1); }} }} }},
15301 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15302 }}
15303 }},
15304 plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
15305 }});
15306 }});
15307 }})();
15308
15309 (function() {{
15310 var btn = document.getElementById('trend-expand-btn');
15311 if (!btn) return;
15312 btn.addEventListener('click', function() {{
15313 var pts = currentTrendPts;
15314 if (!pts || !pts.length) return;
15315 var ctrl = getTrendControls();
15316 var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
15317 var title = meta.label + ' Trend \u2014 Full View';
15318 var canvas = makeTmOverlay(title, pts.length + ' scan' + (pts.length !== 1 ? 's' : ''), 440);
15319 if (!canvas) return;
15320 // Reuse the exact inline-chart config so Full View matches the default view
15321 // (straight segments + gradient-only interactivity), just larger.
15322 new Chart(canvas, buildTmTrendConfig(pts, ctrl, meta));
15323 }});
15324 }})();
15325
15326 (function() {{
15327 var btn = document.getElementById('assertions-expand-btn');
15328 if (!btn) return;
15329 btn.addEventListener('click', function() {{
15330 var D = currentLangTests;
15331 if (!D || !D.length) return;
15332 var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
15333 if (!top15.length) return;
15334 var h = Math.max(320, top15.length * 36 + 80);
15335 var canvas = makeTmOverlay('Assertions by Language \u2014 Full View', top15.length + ' languages', h);
15336 if (!canvas) return;
15337 new Chart(canvas, {{
15338 type: 'bar',
15339 data: {{
15340 labels: top15.map(function(d){{ return d.lang; }}),
15341 datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
15342 }},
15343 options: {{
15344 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15345 layout: {{ padding: {{ right: 72 }} }},
15346 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15347 scales: {{
15348 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
15349 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15350 }}
15351 }},
15352 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15353 }});
15354 }});
15355 }})();
15356
15357 (function() {{
15358 var btn = document.getElementById('suites-expand-btn');
15359 if (!btn) return;
15360 btn.addEventListener('click', function() {{
15361 var D = currentLangTests;
15362 if (!D || !D.length) return;
15363 var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
15364 if (!top15.length) return;
15365 var h = Math.max(320, top15.length * 36 + 80);
15366 var canvas = makeTmOverlay('Test Suites by Language \u2014 Full View', top15.length + ' languages', h);
15367 if (!canvas) return;
15368 new Chart(canvas, {{
15369 type: 'bar',
15370 data: {{
15371 labels: top15.map(function(d){{ return d.lang; }}),
15372 datasets: [{{ label: 'Test Suites', data: top15.map(function(d){{ return d.suites; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+6) % PALETTE.length]; }}), borderRadius: 4 }}]
15373 }},
15374 options: {{
15375 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15376 layout: {{ padding: {{ right: 72 }} }},
15377 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15378 scales: {{
15379 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
15380 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15381 }}
15382 }},
15383 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15384 }});
15385 }});
15386 }})();
15387
15388 // Wire trend control selectors — re-render without re-fetching
15389 (function() {{
15390 ['tm-trend-y','tm-trend-x','tm-trend-size','tm-trend-sub'].forEach(function(id) {{
15391 var el = document.getElementById(id);
15392 if (el) el.addEventListener('change', function() {{ renderTrend(); }});
15393 }});
15394 }})();
15395
15396 function loadTrend() {{
15397 var url = '/api/metrics/history?limit=100';
15398 if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
15399 fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
15400 buildTrend(data);
15401 // Show Multi-Timeline button when >= 2 scans exist for the selected project.
15402 var btn = document.getElementById('multi-compare-trend-btn');
15403 if (btn) {{
15404 var ids = data.filter(function(d){{ return d.run_id; }}).map(function(d){{ return d.run_id; }});
15405 if (ids.length >= 2) {{
15406 btn.style.display = '';
15407 btn.onclick = function() {{
15408 // Reverse so oldest first (API returns newest first).
15409 var sorted = ids.slice().reverse();
15410 if (sorted.length === 2) {{
15411 window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
15412 }} else {{
15413 window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
15414 }}
15415 }};
15416 }} else {{
15417 btn.style.display = 'none';
15418 }}
15419 }}
15420 }}).catch(function(){{
15421 var trendEmpty = document.getElementById('trend-empty');
15422 if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
15423 }});
15424 }}
15425
15426 // Re-render charts on theme toggle
15427 document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
15428 setTimeout(function() {{
15429 ALL_CHARTS.forEach(function(c) {{
15430 if (c && c.options && c.options.scales) {{
15431 Object.values(c.options.scales).forEach(function(ax) {{
15432 if (ax.grid) ax.grid.color = clr();
15433 if (ax.ticks) ax.ticks.color = txtClr();
15434 }});
15435 c.update();
15436 }}
15437 }});
15438 }}, 80);
15439 }});
15440
15441 // ── Export helpers (Excel / PNG / PDF) ───────────────────────────────────
15442 var TM_FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
15443 function tmExportMeta() {{
15444 var sel = document.getElementById('scope-sel');
15445 var proj = sel && sel.options[sel.selectedIndex] ? sel.options[sel.selectedIndex].text : 'All projects';
15446 if (!proj || proj === '__all__') proj = 'All projects';
15447 var now = new Date(); function p2(n) {{ return (n<10?'0':'')+n; }}
15448 var dstr = now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate());
15449 var tstr = p2(now.getHours())+':'+p2(now.getMinutes());
15450 var slug = dstr+'_'+p2(now.getHours())+p2(now.getMinutes());
15451 return {{ proj: proj, date: dstr, time: tstr, slug: slug, full: dstr+' '+tstr }};
15452 }}
15453
15454 function exportTmXLSX() {{
15455 var D = currentLangTests;
15456 if (!D || !D.length) {{ alert('No test data to export yet.'); return; }}
15457 var t = tmExportMeta();
15458 function s2b(s) {{ return new TextEncoder().encode(s); }}
15459 function xe(s) {{ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }}
15460 function col2l(n) {{ var s=''; while(n>0){{var r=(n-1)%26;s=String.fromCharCode(65+r)+s;n=Math.floor((n-1)/26);}} return s; }}
15461 function crc32(d) {{
15462 if(!crc32.t){{crc32.t=new Uint32Array(256);for(var i=0;i<256;i++){{var c=i;for(var j=0;j<8;j++)c=(c&1)?(0xEDB88320^(c>>>1)):(c>>>1);crc32.t[i]=c;}}}}
15463 var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
15464 }}
15465 // Store all cells as strings so Excel left-aligns uniformly.
15466 function cs(addr, val, bold) {{
15467 return '<c r="'+addr+'" t="inlineStr"'+(bold?' s="1"':'')+"><is><t>"+xe(String(val))+'</t></is></c>';
15468 }}
15469 // Build an Excel Table XML definition for a given sheet range and columns.
15470 function makeTableXml(tblId, name, ref, cols) {{
15471 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
15472 x+='<table xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
15473 x+=' id="'+tblId+'" name="'+name+'" displayName="'+name+'" ref="'+ref+'" headerRowCount="1">';
15474 x+='<autoFilter ref="'+ref+'"/>';
15475 x+='<tableColumns count="'+cols.length+'">';
15476 cols.forEach(function(col,i){{x+='<tableColumn id="'+(i+1)+'" name="'+xe(col)+'"/>';}});
15477 x+='</tableColumns>';
15478 x+='<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>';
15479 return x+'</table>';
15480 }}
15481 // Worksheet XML with optional Excel Table part reference.
15482 function buildSheet(hdr, rows, totRow, colWidths, tblRid) {{
15483 var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
15484 if(tblRid)ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';
15485 var cw='<cols>';colWidths.forEach(function(w,i){{cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';}});cw+='</cols>';
15486 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>'+cw+'<sheetData>';
15487 x+='<row r="1">';hdr.forEach(function(h,ci){{x+=cs(col2l(ci+1)+'1',h,true);}});x+='</row>';
15488 rows.forEach(function(row,ri){{var rn=ri+2;x+='<row r="'+rn+'">';row.forEach(function(cell,ci){{x+=cs(col2l(ci+1)+rn,cell,false);}});x+='</row>';}});
15489 if(totRow){{var rn=rows.length+2;x+='<row r="'+rn+'">';totRow.forEach(function(cell,ci){{x+=cs(col2l(ci+1)+rn,cell,true);}});x+='</row>';}}
15490 x+='</sheetData>';
15491 if(tblRid)x+='<tableParts count="1"><tablePart r:id="'+tblRid+'"/></tableParts>';
15492 return x+'</worksheet>';
15493 }}
15494
15495 var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
15496 var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
15497 var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
15498 var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
15499 var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
15500 var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
15501
15502 // ── Build the worksheet list (test metrics + optional LCOV coverage) ──
15503 // Each entry: {{name, tbl (Excel table name), hdr, rows, tot, cols}}.
15504 var sheets=[];
15505
15506 // Sheet: Summary
15507 var sumHdr=['Metric','Value'];
15508 var sumRows=[
15509 ['Project / Scope', t.proj],
15510 ['Export Date', t.full],
15511 ['Test Functions', Number(totTests).toLocaleString()],
15512 ['Assertions', Number(totAssert).toLocaleString()],
15513 ['Test Suites', Number(totSuites).toLocaleString()],
15514 ['Languages with Tests', String(D.length)],
15515 ['Total Code Lines', Number(totCode).toLocaleString()],
15516 ['Average Density (per 1K)', String(avgDensity)],
15517 ];
15518 sheets.push({{name:'Summary',tbl:'Summary',hdr:sumHdr,rows:sumRows,tot:null,cols:[28,22]}});
15519
15520 // Sheet: Language Breakdown (TOTAL row sits just below the table range)
15521 var langHdr=['Language','Test Functions','Assertions','Test Suites','Code Lines','Files','Density (per 1K)'];
15522 var langRows=D.map(function(d){{return[d.lang,Number(d.tests).toLocaleString(),Number(d.assertions||0).toLocaleString(),Number(d.suites||0).toLocaleString(),Number(d.code).toLocaleString(),Number(d.files).toLocaleString(),Number(d.density).toFixed(2)];}});
15523 var totRow=['TOTAL',Number(totTests).toLocaleString(),Number(totAssert).toLocaleString(),Number(totSuites).toLocaleString(),Number(totCode).toLocaleString(),Number(totFiles).toLocaleString(),String(avgDensity)];
15524 sheets.push({{name:'Language Breakdown',tbl:'LangBreakdown',hdr:langHdr,rows:langRows,tot:totRow,cols:[22,15,15,15,15,12,15]}});
15525
15526 // Sheets: LCOV Coverage Summary (appended only when the current scope has coverage)
15527 var covDs=(typeof getDataset==='function')?getDataset():null;
15528 if(covDs&&covDs.has_coverage){{
15529 var covT=covDs.totals||{{}};
15530 var covSumHdr=['Metric','Value'];
15531 var covSumRows=[
15532 ['Line Coverage', (covT.cov_line||'0')+'%'],
15533 ['Function Coverage', (covT.cov_fn||'0')+'%'],
15534 ['Branch Coverage', (covT.cov_branch||'0')+'%'],
15535 ];
15536 if(covDs.cov_tiers){{
15537 covSumRows.push(['Files High (≥80%)', String(covDs.cov_tiers.high||0)]);
15538 covSumRows.push(['Files Moderate (50–79%)', String(covDs.cov_tiers.mid||0)]);
15539 covSumRows.push(['Files Low (<50%)', String(covDs.cov_tiers.low||0)]);
15540 }}
15541 sheets.push({{name:'Coverage Summary',tbl:'CoverageSummary',hdr:covSumHdr,rows:covSumRows,tot:null,cols:[26,14]}});
15542
15543 if(covDs.cov&&covDs.cov.length){{
15544 var covLangHdr=['Language','Line Coverage %'];
15545 var covLangRows=covDs.cov.map(function(c){{return[c.lang,Number(c.pct).toFixed(1)];}});
15546 sheets.push({{name:'Coverage by Language',tbl:'CoverageByLang',hdr:covLangHdr,rows:covLangRows,tot:null,cols:[24,18]}});
15547 }}
15548 if(covFileData&&covFileData.length){{
15549 var covFileHdr=['File','Language','Line %','Lines Hit','Lines Found','Function %','Fns Hit','Fns Found'];
15550 var covFileRows=covFileData.map(function(f){{
15551 var noFn=f.fn_pct<0;
15552 return[f.rel,f.lang,Number(f.line_pct).toFixed(1),String(f.lhit),String(f.lfound),noFn?'—':Number(f.fn_pct).toFixed(1),noFn?'—':String(f.fhit),noFn?'—':String(f.ffound)];
15553 }});
15554 sheets.push({{name:'Coverage by File',tbl:'CoverageByFile',hdr:covFileHdr,rows:covFileRows,tot:null,cols:[40,14,10,10,12,12,10,10]}});
15555 }}
15556 }}
15557
15558 var styl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><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"/></cellXfs></styleSheet>';
15559 var dotrels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>';
15560
15561 // Assemble per-sheet parts, content-type overrides, and workbook relationships.
15562 var files=[];
15563 var ctOverrides='', wbSheetTags='', wbRelTags='';
15564 sheets.forEach(function(sh,i){{
15565 var n=i+1;
15566 var lastCol=col2l(sh.hdr.length);
15567 var ref='A1:'+lastCol+(sh.rows.length+1);
15568 var sheetXml=buildSheet(sh.hdr,sh.rows,sh.tot,sh.cols,'rId1');
15569 var tblXml=makeTableXml(n,sh.tbl,ref,sh.hdr);
15570 var shRels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/table" Target="../tables/table'+n+'.xml"/></Relationships>';
15571 files.push({{name:'xl/worksheets/sheet'+n+'.xml',data:s2b(sheetXml)}});
15572 files.push({{name:'xl/worksheets/_rels/sheet'+n+'.xml.rels',data:s2b(shRels)}});
15573 files.push({{name:'xl/tables/table'+n+'.xml',data:s2b(tblXml)}});
15574 ctOverrides+='<Override PartName="/xl/worksheets/sheet'+n+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';
15575 ctOverrides+='<Override PartName="/xl/tables/table'+n+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';
15576 wbSheetTags+='<sheet name="'+xe(sh.name)+'" sheetId="'+n+'" r:id="rId'+n+'"/>';
15577 wbRelTags+='<Relationship Id="rId'+n+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet'+n+'.xml"/>';
15578 }});
15579 var styleRid='rId'+(sheets.length+1);
15580 var ct='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/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"/>'+ctOverrides+'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
15581 var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="'+styleRid+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>'+wbRelTags+'</Relationships>';
15582 var wbx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>'+wbSheetTags+'</sheets></workbook>';
15583 files.unshift(
15584 {{name:'[Content_Types].xml',data:s2b(ct)}},
15585 {{name:'_rels/.rels',data:s2b(dotrels)}},
15586 {{name:'xl/workbook.xml',data:s2b(wbx)}},
15587 {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
15588 {{name:'xl/styles.xml',data:s2b(styl)}}
15589 );
15590 var parts=[],offsets=[],total=0;
15591 files.forEach(function(f){{offsets.push(total);var nb=s2b(f.name),crc=crc32(f.data);var h=new DataView(new ArrayBuffer(30+nb.length));h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);h.setUint16(26,nb.length,true);h.setUint16(28,0,true);for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);parts.push(new Uint8Array(h.buffer));parts.push(f.data);total+=30+nb.length+f.data.length;}});
15592 var cdStart=total;files.forEach(function(f,fi){{var nb=s2b(f.name),crc=crc32(f.data);var cd=new DataView(new ArrayBuffer(46+nb.length));cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;}});
15593 var cdSz=total-cdStart;var eocd=new DataView(new ArrayBuffer(22));eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);parts.push(new Uint8Array(eocd.buffer));
15594 var sz=parts.reduce(function(a,p){{return a+p.length;}},0);var out=new Uint8Array(sz);var off=0;parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
15595 var proj2=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15596 var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj2+'-'+t.slug+'.xlsx';
15597 a.href=URL.createObjectURL(new Blob([out.buffer],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
15598 a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
15599 }}
15600
15601 function exportTmPNG() {{
15602 // Map canvas IDs to display titles
15603 var CHART_TITLES = {{
15604 'canvas-trend': 'TEST COUNT TREND',
15605 'canvas-tests': 'TEST DEFINITIONS BY LANGUAGE',
15606 'canvas-density': 'TEST DENSITY (per 1,000 code lines)',
15607 'canvas-assertions': 'ASSERTIONS BY LANGUAGE',
15608 'canvas-suites': 'TEST SUITES BY LANGUAGE',
15609 'canvas-files': 'TEST FILES BREAKDOWN',
15610 'canvas-composition': 'TEST COMPOSITION',
15611 'canvas-cov': 'LINE COVERAGE % BY LANGUAGE',
15612 'canvas-cov-tiers': 'COVERAGE TIER DISTRIBUTION'
15613 }};
15614 // Coverage canvases are only appended when the LCOV panel is visible (has data).
15615 var covPanelEl=document.getElementById('cov-panel');
15616 var covShown=covPanelEl&&covPanelEl.style.display!=='none';
15617 var ids=['canvas-trend','canvas-tests','canvas-density','canvas-assertions','canvas-suites','canvas-files','canvas-composition'];
15618 if(covShown){{ids.push('canvas-cov','canvas-cov-tiers');}}
15619 // Include only charts that actually rendered data. A "no data" chart has its
15620 // canvas wrap hidden (offsetParent===null) with a placeholder shown instead —
15621 // skip those so the image has no empty gaps (e.g. Assertions/Suites at 0).
15622 function chartHasData(c){{return c&&c.width>0&&c.offsetParent!==null;}}
15623 var canvases=ids.map(function(id){{return document.getElementById(id);}}).filter(chartHasData);
15624 if(!canvases.length){{alert('No charts rendered yet. Run a scan first.');return;}}
15625 var t=tmExportMeta();
15626 var COLW=760, GAP=16, HEADER_H=102, FOOTER_H=40, ROW_PAD=18, TITLE_H=26;
15627 var trendCanvas=document.getElementById('canvas-trend');
15628 var hasTrend=chartHasData(trendCanvas);
15629 var gridCanvases=canvases.filter(function(c){{return c.id!=='canvas-trend';}});
15630 var TOTAL_W=COLW*2+GAP;
15631 var TREND_H=hasTrend?Math.round(TOTAL_W*(trendCanvas.height/Math.max(trendCanvas.width,1))):0;
15632 TREND_H=Math.min(Math.max(200,TREND_H),340);
15633 // Per-row chart heights (2-col grid)
15634 var gridRows=Math.ceil(gridCanvases.length/2);
15635 var rowHeights=[];
15636 for(var ri=0;ri<gridRows;ri++){{
15637 var rh=240;
15638 for(var ci=0;ci<2;ci++){{
15639 var cv=gridCanvases[ri*2+ci];
15640 if(cv&&cv.width>0){{
15641 var nat=Math.round(COLW*cv.height/Math.max(cv.width,1));
15642 rh=Math.max(rh,Math.min(420,nat));
15643 }}
15644 }}
15645 rowHeights.push(rh);
15646 }}
15647 var gridH=rowHeights.reduce(function(a,b){{return a+TITLE_H+b+ROW_PAD;}},0);
15648 var trendSection=hasTrend?TITLE_H+TREND_H+ROW_PAD:0;
15649 var TOTAL_H=HEADER_H+trendSection+gridH+FOOTER_H;
15650 var out=document.createElement('canvas');out.width=TOTAL_W;out.height=TOTAL_H;
15651 var ctx=out.getContext('2d');
15652 var cs2=getComputedStyle(document.body);
15653 var bg=cs2.getPropertyValue('--bg').trim()||'#f5efe8';
15654 var oxide=cs2.getPropertyValue('--oxide').trim()||'#C45C10';
15655 var muted=cs2.getPropertyValue('--muted').trim()||'#7b675b';
15656
15657 // Background
15658 ctx.fillStyle=bg;ctx.fillRect(0,0,TOTAL_W,TOTAL_H);
15659
15660 // Orange header block
15661 ctx.fillStyle=oxide;ctx.fillRect(0,0,TOTAL_W,HEADER_H-8);
15662 ctx.fillStyle='#fff';ctx.font='800 24px '+TM_FONT;ctx.textBaseline='alphabetic';ctx.textAlign='left';
15663 ctx.fillText('Test Metrics — '+t.proj,22,42);
15664 ctx.fillStyle='rgba(255,255,255,0.82)';ctx.font='600 13px '+TM_FONT;
15665 ctx.fillText('oxide-sloc v{version} · Generated '+t.full,22,70);
15666 ctx.fillStyle=bg;ctx.fillRect(0,HEADER_H-8,TOTAL_W,TOTAL_H-(HEADER_H-8));
15667
15668 // Helper: draw a section title label
15669 function drawTitle(label, x, y, w) {{
15670 ctx.save();
15671 ctx.fillStyle=oxide;
15672 ctx.font='700 11px '+TM_FONT;
15673 ctx.textBaseline='middle';
15674 ctx.textAlign='left';
15675 ctx.letterSpacing='0.07em';
15676 ctx.fillText(label, x+2, y+TITLE_H/2);
15677 // Underline
15678 ctx.strokeStyle=oxide;ctx.globalAlpha=0.35;ctx.lineWidth=1;
15679 ctx.beginPath();ctx.moveTo(x,y+TITLE_H-2);ctx.lineTo(x+w,y+TITLE_H-2);ctx.stroke();
15680 ctx.globalAlpha=1;
15681 ctx.restore();
15682 }}
15683
15684 var yOff=HEADER_H;
15685
15686 // Trend chart (full width)
15687 if(hasTrend){{
15688 drawTitle(CHART_TITLES['canvas-trend']||'TEST COUNT TREND', 4, yOff, TOTAL_W-8);
15689 yOff+=TITLE_H;
15690 var surf=document.createElement('canvas');surf.width=TOTAL_W;surf.height=TREND_H;
15691 var sc=surf.getContext('2d');sc.fillStyle=bg;sc.fillRect(0,0,TOTAL_W,TREND_H);
15692 sc.drawImage(trendCanvas,0,0,TOTAL_W,TREND_H);
15693 ctx.drawImage(surf,0,yOff);
15694 yOff+=TREND_H+ROW_PAD;
15695 }}
15696
15697 // Grid charts (2-col), each cell gets title + chart
15698 for(var gi=0;gi<gridRows;gi++){{
15699 var rh2=rowHeights[gi];
15700 // Draw row titles and charts
15701 for(var gci=0;gci<2;gci++){{
15702 var idx2=gi*2+gci;
15703 if(idx2>=gridCanvases.length)continue;
15704 var gcv=gridCanvases[idx2];
15705 var gx=gci*(COLW+GAP);
15706 drawTitle(CHART_TITLES[gcv.id]||gcv.id.replace('canvas-','').toUpperCase(), gx+4, yOff, COLW-8);
15707 }}
15708 yOff+=TITLE_H;
15709 for(var gci2=0;gci2<2;gci2++){{
15710 var idx3=gi*2+gci2;
15711 if(idx3>=gridCanvases.length)continue;
15712 var gcv2=gridCanvases[idx3];
15713 var gx2=gci2*(COLW+GAP);
15714 var natW=gcv2.width,natH=gcv2.height;
15715 var scale=Math.min(COLW/Math.max(natW,1),rh2/Math.max(natH,1));
15716 var dw=Math.round(natW*scale),dh=Math.round(natH*scale);
15717 var surf2=document.createElement('canvas');surf2.width=COLW;surf2.height=rh2;
15718 var sc2=surf2.getContext('2d');sc2.fillStyle=bg;sc2.fillRect(0,0,COLW,rh2);
15719 sc2.drawImage(gcv2,Math.round((COLW-dw)/2),Math.round((rh2-dh)/2),dw,dh);
15720 ctx.drawImage(surf2,gx2,yOff);
15721 }}
15722 yOff+=rh2+ROW_PAD;
15723 }}
15724
15725 // Dark footer
15726 ctx.fillStyle='#43342d';ctx.fillRect(0,TOTAL_H-FOOTER_H,TOTAL_W,FOOTER_H);
15727 ctx.fillStyle='rgba(255,255,255,0.72)';ctx.font='600 11px '+TM_FONT;ctx.textAlign='center';
15728 ctx.fillText('© 2026 OxideSLOC · oxide-sloc v{version} · AGPL-3.0-or-later',TOTAL_W/2,TOTAL_H-FOOTER_H+24);
15729
15730 var proj3=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15731 var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj3+'-'+t.slug+'.png';a.href=out.toDataURL('image/png');a.click();
15732 }}
15733
15734 function exportTmPDF(ev) {{
15735 var D=currentLangTests;
15736 var t=tmExportMeta();
15737 var strips=document.querySelectorAll('.summary-strip');
15738 var statsHtml='';strips.forEach(function(s){{statsHtml+=s.outerHTML;}});
15739 var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
15740 var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
15741 var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
15742 var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
15743 var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
15744 var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
15745 var rows='';
15746 (D||[]).forEach(function(d){{
15747 rows+='<tr><td><strong>'+d.lang+'</strong></td>'
15748 +'<td class="n">'+Number(d.tests).toLocaleString()+'</td>'
15749 +'<td class="n">'+Number(d.assertions||0).toLocaleString()+'</td>'
15750 +'<td class="n">'+Number(d.suites||0).toLocaleString()+'</td>'
15751 +'<td class="n">'+Number(d.code).toLocaleString()+'</td>'
15752 +'<td class="n">'+Number(d.files).toLocaleString()+'</td>'
15753 +'<td class="n">'+Number(d.density).toFixed(2)+'</td></tr>';
15754 }});
15755 var totRow='<tr class="tot-row"><td><strong>TOTAL</strong></td>'
15756 +'<td class="n"><strong>'+Number(totTests).toLocaleString()+'</strong></td>'
15757 +'<td class="n"><strong>'+Number(totAssert).toLocaleString()+'</strong></td>'
15758 +'<td class="n"><strong>'+Number(totSuites).toLocaleString()+'</strong></td>'
15759 +'<td class="n"><strong>'+Number(totCode).toLocaleString()+'</strong></td>'
15760 +'<td class="n"><strong>'+Number(totFiles).toLocaleString()+'</strong></td>'
15761 +'<td class="n"><strong>'+avgDensity+'</strong></td></tr>';
15762 var tableHtml='<table><thead><tr><th>Language</th><th class="n">Test Fns</th><th class="n">Assertions</th><th class="n">Suites</th><th class="n">Code Lines</th><th class="n">Files</th><th class="n">Density/1K</th></tr></thead><tbody>'+rows+totRow+'</tbody></table>';
15763 var css='<style>*{{box-sizing:border-box;margin:0;padding:0;}}'
15764 +'html,body{{height:100%;margin:0;}}'
15765 +'body{{font-family:Inter,system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:#241813;background:#fff;display:flex;flex-direction:column;min-height:100vh;}}'
15766 +'.rep-header{{background:#C45C10;color:#fff;padding:18px 32px 16px;display:flex;justify-content:space-between;align-items:flex-start;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'
15767 +'.rep-header h1{{font-size:22px;font-weight:900;margin:0;color:#fff;}}'
15768 +'.rep-header .sub{{font-size:12px;margin:5px 0 0;color:rgba(255,255,255,0.85);}}'
15769 +'.rep-brand{{font-size:14px;font-weight:800;color:#fff;text-align:right;}}'
15770 +'.rep-brand small{{display:block;font-weight:500;font-size:11px;opacity:.85;margin-top:2px;}}'
15771 +'.rep-body{{padding:20px 32px;flex:1;}}'
15772 +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 12px;}}'
15773 +'.stat-chip{{border:1px solid #e6d0bf;border-radius:10px;padding:10px 12px;position:relative;}}'
15774 +'.stat-chip-tip,.stat-chip-exact{{display:none!important;}}'
15775 +'.stat-chip-val{{font-size:17px;font-weight:900;color:#C45C10;}}'
15776 +'.stat-chip-label{{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#7b675b;margin-top:3px;}}'
15777 +'.section-hdr{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:#C45C10;margin:16px 0 8px;border-bottom:2px solid #C45C10;padding-bottom:4px;}}'
15778 +'table{{border-collapse:collapse;width:100%;font-size:11px;margin-top:4px;}}'
15779 +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;white-space:nowrap;}}'
15780 +'th{{background:#f5efe8;font-weight:800;font-size:10px;}}'
15781 +'.n{{text-align:right;}}'
15782 +'.tot-row td{{background:#f0e6dc;border-top:2px solid #C45C10;}}'
15783 +'.cov-strip{{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:4px 0 8px;}}'
15784 +'.cov-card{{border:1px solid #e6d0bf;border-radius:10px;padding:10px 12px;}}'
15785 +'.cov-k{{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#7b675b;}}'
15786 +'.cov-v{{font-size:18px;font-weight:900;color:#2a6846;margin-top:3px;}}'
15787 +'.rep-footer{{background:#43342d;color:rgba(255,255,255,0.75);padding:10px 32px;font-size:10px;text-align:center;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'
15788 +'</style>';
15789 // LCOV Coverage Summary section — only rendered when the current scope has coverage.
15790 var covDs=(typeof getDataset==='function')?getDataset():null;
15791 var covHtml='';
15792 if(covDs&&covDs.has_coverage){{
15793 var covT=covDs.totals||{{}};
15794 covHtml+='<div class="section-hdr">LCOV Coverage Summary</div>'
15795 +'<div class="cov-strip">'
15796 +'<div class="cov-card"><div class="cov-k">Line Coverage</div><div class="cov-v">'+(covT.cov_line||'0')+'%</div></div>'
15797 +'<div class="cov-card"><div class="cov-k">Function Coverage</div><div class="cov-v">'+(covT.cov_fn||'0')+'%</div></div>'
15798 +'<div class="cov-card"><div class="cov-k">Branch Coverage</div><div class="cov-v">'+(covT.cov_branch||'0')+'%</div></div>'
15799 +'</div>';
15800 if(covFileData&&covFileData.length){{
15801 var cfrows='';
15802 covFileData.forEach(function(f){{
15803 var noFn=f.fn_pct<0;
15804 cfrows+='<tr><td>'+f.rel+'</td><td>'+f.lang+'</td>'
15805 +'<td class="n">'+Number(f.line_pct).toFixed(1)+'%</td>'
15806 +'<td class="n">'+f.lhit+' / '+f.lfound+'</td>'
15807 +'<td class="n">'+(noFn?'—':Number(f.fn_pct).toFixed(1)+'%')+'</td>'
15808 +'<td class="n">'+(noFn?'—':f.fhit+' / '+f.ffound)+'</td></tr>';
15809 }});
15810 covHtml+='<div class="section-hdr">Coverage File Detail</div>'
15811 +'<table><thead><tr><th>File</th><th>Lang</th><th class="n">Line %</th><th class="n">Lines Hit / Found</th><th class="n">Fn %</th><th class="n">Fns Hit / Found</th></tr></thead><tbody>'+cfrows+'</tbody></table>';
15812 }}
15813 }}
15814 var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Test Metrics</title>'+css+'</head><body>'
15815 +'<div class="rep-header"><div><h1>Test Metrics Report</h1><p class="sub">Scope: '+t.proj+' · Generated: '+t.full+'</p></div>'
15816 +'<div class="rep-brand">OxideSLOC<small>oxide-sloc v{version}</small></div></div>'
15817 +'<div class="rep-body">'+statsHtml
15818 +'<div class="section-hdr">Language Breakdown</div>'
15819 +tableHtml+covHtml+'</div>'
15820 +'<div class="rep-footer">© 2026 OxideSLOC · oxide-sloc v{version} · local code metrics workbench · AGPL-3.0-or-later · Generated '+t.full+'</div>'
15821 +'</body></html>';
15822 var proj4=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15823 var pdfBtn=(ev&&ev.currentTarget)||document.getElementById('tm-export-pdf-btn');
15824 window.slocExportPdf({{html:doc,filename:'oxide-sloc-test-metrics-'+proj4+'-'+t.slug+'.pdf',button:pdfBtn}});
15825 }}
15826
15827 (function() {{
15828 // Page-level export controls (Scope toolbar). Every button exports the ENTIRE
15829 // Test Metrics page — test metrics + the LCOV Coverage Summary — for the scope.
15830 var xBtn=document.getElementById('tm-export-xlsx-btn');
15831 var pngBtn=document.getElementById('tm-export-png-btn');
15832 var pdfBtn=document.getElementById('tm-export-pdf-btn');
15833 if(xBtn)xBtn.addEventListener('click',exportTmXLSX);
15834 if(pngBtn)pngBtn.addEventListener('click',exportTmPNG);
15835 if(pdfBtn)pdfBtn.addEventListener('click',exportTmPDF);
15836 }})();
15837
15838 applyScope();
15839 }})();
15840 </script>
15841 <script nonce="{nonce}">(function(){{var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{version} \u2014 Mode: '+(isServer?'Network Server':'Local');function setDot(ms){{if(!dot)return;if(ms<100){{dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}}else if(ms<300){{dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}}else{{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}}function doPing(){{var t0=performance.now();fetch('/healthz',{{cache:'no-store'}}).then(function(){{var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}}).catch(function(){{if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}}});}}doPing();setInterval(doPing,5000);}})();</script>
15842 {toast_assets}
15843</body>
15844</html>"#,
15845 );
15846 (
15847 [(axum::http::header::CACHE_CONTROL, "no-store")],
15848 Html(html),
15849 )
15850 .into_response()
15851}
15852
15853#[derive(Deserialize)]
15860struct EmbedQuery {
15861 run_id: Option<String>,
15862 theme: Option<String>,
15863}
15864
15865async fn embed_handler(
15866 State(state): State<AppState>,
15867 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
15868 Query(query): Query<EmbedQuery>,
15869) -> Response {
15870 let entry = {
15871 let reg = state.registry.lock().await;
15872 query.run_id.as_ref().map_or_else(
15873 || reg.entries.first().cloned(),
15874 |id| reg.find_by_run_id(id).cloned(),
15875 )
15876 };
15877
15878 let Some(entry) = entry else {
15879 return Html(
15880 "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
15881 .to_string(),
15882 )
15883 .into_response();
15884 };
15885
15886 let dark = query.theme.as_deref() == Some("dark");
15887 let languages: Vec<(String, u64, u64)> = entry
15888 .json_path
15889 .as_ref()
15890 .and_then(|p| read_json(p).ok())
15891 .map(|run| {
15892 run.totals_by_language
15893 .iter()
15894 .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
15895 .collect()
15896 })
15897 .unwrap_or_default();
15898
15899 Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
15900}
15901
15902fn render_embed_widget(
15903 entry: &RegistryEntry,
15904 languages: &[(String, u64, u64)],
15905 dark: bool,
15906 csp_nonce: &str,
15907) -> String {
15908 let s = &entry.summary;
15909 let total = s.code_lines + s.comment_lines + s.blank_lines;
15910 let code_pct = s
15911 .code_lines
15912 .checked_mul(100)
15913 .and_then(|n| n.checked_div(total))
15914 .unwrap_or(0);
15915
15916 let (bg, fg, surface, muted, border) = if dark {
15917 ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
15918 } else {
15919 ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
15920 };
15921
15922 let mut lang_rows = String::new();
15923 for (name, files, code) in languages {
15924 write!(
15925 lang_rows,
15926 "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
15927 escape_html(name),
15928 format_number(*files),
15929 format_number(*code),
15930 )
15931 .ok();
15932 }
15933
15934 let lang_table = if lang_rows.is_empty() {
15935 String::new()
15936 } else {
15937 format!(
15938 "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
15939 )
15940 };
15941
15942 let run_short = &entry.run_id[..entry.run_id.len().min(8)];
15943 let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
15944 let project_esc = escape_html(&entry.project_label);
15945 let code_lines = format_number(s.code_lines);
15946 let comment_lines = format_number(s.comment_lines);
15947 let files = format_number(s.files_analyzed);
15948 let code_raw = s.code_lines;
15949 let comment_raw = s.comment_lines;
15950 let blank_raw = s.blank_lines;
15951
15952 format!(
15953 r#"<!doctype html>
15954<html lang="en">
15955<head>
15956 <meta charset="utf-8">
15957 <meta name="viewport" content="width=device-width,initial-scale=1">
15958 <title>OxideSLOC — {project_esc}</title>
15959 <script src="/static/chart.js"></script>
15960 <style nonce="{csp_nonce}">
15961 *{{box-sizing:border-box;margin:0;padding:0}}
15962 body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
15963 h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
15964 .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
15965 .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
15966 .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
15967 .card .v{{font-size:18px;font-weight:700}}
15968 .card .l{{color:{muted};font-size:10px;margin-top:2px}}
15969 .row{{display:flex;gap:12px;align-items:flex-start}}
15970 .pie{{width:120px;height:120px;flex-shrink:0}}
15971 .lt{{border-collapse:collapse;width:100%;flex:1}}
15972 .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
15973 .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
15974 .n{{text-align:right}}
15975 .footer{{margin-top:10px;color:{muted};font-size:10px}}
15976 </style>
15977</head>
15978<body>
15979 <h2>{project_esc}</h2>
15980 <div class="sub">{timestamp} · run {run_short}</div>
15981 <div class="cards">
15982 <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
15983 <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
15984 <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
15985 <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
15986 </div>
15987 <div class="row">
15988 <canvas class="pie" id="c"></canvas>
15989 {lang_table}
15990 </div>
15991 <div class="footer">oxide-sloc</div>
15992 <script nonce="{csp_nonce}">
15993 new Chart(document.getElementById('c'),{{
15994 type:'doughnut',
15995 data:{{
15996 labels:['Code','Comments','Blank'],
15997 datasets:[{{
15998 data:[{code_raw},{comment_raw},{blank_raw}],
15999 backgroundColor:['#4a78ee','#b35428','#aaa'],
16000 borderWidth:0
16001 }}]
16002 }},
16003 options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
16004 }});
16005 </script>
16006</body>
16007</html>"#
16008 )
16009}
16010
16011fn output_dir_lock(dir: &Path) -> Arc<std::sync::Mutex<()>> {
16016 static LOCKS: OnceLock<std::sync::Mutex<HashMap<PathBuf, Arc<std::sync::Mutex<()>>>>> =
16017 OnceLock::new();
16018 let map = LOCKS.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
16019 let mut guard = map
16020 .lock()
16021 .unwrap_or_else(std::sync::PoisonError::into_inner);
16022 guard
16023 .entry(dir.to_path_buf())
16024 .or_insert_with(|| Arc::new(std::sync::Mutex::new(())))
16025 .clone()
16026}
16027
16028#[allow(clippy::too_many_lines)]
16029fn persist_run_artifacts(
16030 run: &sloc_core::AnalysisRun,
16031 report_html: &str,
16032 run_dir: &Path,
16033 report_title: &str,
16034 file_stem: &str,
16035 result_context: RunResultContext,
16036) -> Result<(RunArtifacts, PendingPdf)> {
16037 let dir_lock = output_dir_lock(run_dir);
16040 let _dir_guard = dir_lock
16041 .lock()
16042 .unwrap_or_else(std::sync::PoisonError::into_inner);
16043
16044 let html_dir = run_dir.join("html");
16046 let pdf_dir = run_dir.join("pdf");
16047 let excel_dir = run_dir.join("excel");
16048 let json_dir = run_dir.join("json");
16049 let submodules_dir = run_dir.join("submodules");
16050 for dir in &[
16051 run_dir,
16052 &html_dir,
16053 &pdf_dir,
16054 &excel_dir,
16055 &json_dir,
16056 &submodules_dir,
16057 ] {
16058 fs::create_dir_all(dir)
16059 .with_context(|| format!("failed to create directory {}", dir.display()))?;
16060 }
16061
16062 let html_path = {
16064 let path = html_dir.join(format!("report_{file_stem}.html"));
16065 fs::write(&path, report_html)
16066 .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
16067 Some(path)
16068 };
16069
16070 let json_path = {
16072 let path = json_dir.join(format!("result_{file_stem}.json"));
16073 let json = serde_json::to_string_pretty(run)
16074 .context("failed to serialize analysis run to JSON")?;
16075 fs::write(&path, json)
16076 .with_context(|| format!("failed to write JSON result to {}", path.display()))?;
16077 Some(path)
16078 };
16079
16080 let (pdf_path, pending_pdf) = {
16082 let pdf_dest = pdf_dir.join(format!("report_{file_stem}.pdf"));
16083 match write_pdf_from_run(run, &pdf_dest) {
16084 Ok(()) => {
16085 eprintln!(
16086 "[oxide-sloc][pdf] native PDF written to {}",
16087 pdf_dest.display()
16088 );
16089 (Some(pdf_dest), None)
16090 }
16091 Err(native_err) => {
16092 eprintln!(
16093 "[oxide-sloc][pdf] native PDF failed ({native_err:#}), scheduling HTML->browser fallback"
16094 );
16095 let source_html_path = html_path
16096 .as_ref()
16097 .expect("html_path always Some here")
16098 .clone();
16099 let pending = Some((source_html_path, pdf_dest.clone(), false));
16100 (Some(pdf_dest), pending)
16101 }
16102 }
16103 };
16104
16105 let csv_path = {
16107 let path = excel_dir.join(format!("report_{file_stem}.csv"));
16108 if let Err(e) = sloc_report::write_csv(run, &path) {
16109 eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
16110 None
16111 } else {
16112 Some(path)
16113 }
16114 };
16115
16116 let xlsx_path = {
16117 let path = excel_dir.join(format!("report_{file_stem}.xlsx"));
16118 if let Err(e) = sloc_report::write_xlsx(run, &path) {
16119 eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
16120 None
16121 } else {
16122 Some(path)
16123 }
16124 };
16125
16126 let scan_config_path = Some(json_dir.join(format!("scan-config_{file_stem}.json")));
16128
16129 if run.effective_configuration.discovery.submodule_breakdown {
16131 let run_id = &run.tool.run_id;
16132 for s in &run.submodule_summaries {
16133 build_submodule_row(s, run, run_id, run_dir);
16134 }
16135 }
16136
16137 generate_offline_index(
16139 run,
16140 run_dir,
16141 file_stem,
16142 html_path.as_deref(),
16143 pdf_path.as_deref(),
16144 json_path.as_deref(),
16145 scan_config_path.as_deref(),
16146 &result_context,
16147 );
16148
16149 Ok((
16150 RunArtifacts {
16151 output_dir: run_dir.to_path_buf(),
16152 html_path,
16153 pdf_path,
16154 json_path,
16155 csv_path,
16156 xlsx_path,
16157 scan_config_path,
16158 report_title: report_title.to_string(),
16159 result_context,
16160 },
16161 pending_pdf,
16162 ))
16163}
16164
16165#[allow(clippy::too_many_arguments)]
16168#[allow(clippy::too_many_lines)]
16169#[allow(clippy::similar_names)]
16170fn generate_offline_index(
16171 run: &sloc_core::AnalysisRun,
16172 run_dir: &Path,
16173 file_stem: &str,
16174 html_path: Option<&Path>,
16175 pdf_path: Option<&Path>,
16176 json_path: Option<&Path>,
16177 scan_config_path: Option<&Path>,
16178 result_context: &RunResultContext,
16179) {
16180 let prev_entry = &result_context.prev_entry;
16181 let prev_scan_count = result_context.prev_scan_count;
16182 let project_path = &result_context.project_path;
16183
16184 let scan_delta = prev_entry.as_ref().and_then(|prev| {
16185 prev.json_path
16186 .as_ref()
16187 .and_then(|p| read_json(p).ok())
16188 .map(|prev_run| compute_delta(&prev_run, run))
16189 });
16190
16191 let files_analyzed = run.per_file_records.len() as u64;
16192 let files_skipped = run.skipped_file_records.len() as u64;
16193 let totals = sum_lang_totals(run);
16194
16195 let DeltaFields {
16196 prev_fa_str,
16197 prev_fs_str,
16198 prev_pl_str,
16199 prev_cl_str,
16200 prev_cml_str,
16201 prev_bl_str,
16202 delta_fa_str,
16203 delta_fa_class,
16204 delta_fs_str,
16205 delta_fs_class,
16206 delta_pl_str,
16207 delta_pl_class,
16208 delta_cl_str,
16209 delta_cl_class,
16210 delta_cml_str,
16211 delta_cml_class,
16212 delta_bl_str,
16213 delta_bl_class,
16214 delta_lines_added,
16215 delta_lines_removed,
16216 delta_lines_net_str,
16217 delta_lines_net_class,
16218 } = compute_delta_fields(
16219 prev_entry.as_ref(),
16220 &totals,
16221 files_analyzed,
16222 files_skipped,
16223 scan_delta.as_ref(),
16224 );
16225
16226 let git_commit_url = git_commit_url_for(run);
16227 let git_branch_url = git_branch_url_for(run);
16228 let scan_performed_by = scan_performed_by(run);
16229
16230 let make_rel = |p: Option<&Path>| -> Option<String> {
16232 p.and_then(|abs| abs.strip_prefix(run_dir).ok())
16233 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
16234 };
16235
16236 let run_id = &run.tool.run_id;
16237
16238 let submodule_rows: Vec<SubmoduleRow> = run
16240 .submodule_summaries
16241 .iter()
16242 .map(|s| {
16243 let safe = sanitize_project_label(&s.name);
16244 let key = format!("sub_{safe}");
16245 let sub_path = run_dir.join("submodules").join(format!("{key}.html"));
16246 SubmoduleRow {
16247 name: s.name.clone(),
16248 relative_path: s.relative_path.clone(),
16249 files_analyzed: s.files_analyzed,
16250 code_lines: s.code_lines,
16251 comment_lines: s.comment_lines,
16252 blank_lines: s.blank_lines,
16253 total_physical_lines: s.total_physical_lines,
16254 html_url: if sub_path.exists() {
16255 Some(format!("submodules/{key}.html"))
16256 } else {
16257 None
16258 },
16259 }
16260 })
16261 .collect();
16262
16263 let lang_chart_json = build_lang_chart_json(run);
16264
16265 let scan_config_rel =
16266 make_rel(scan_config_path).unwrap_or_else(|| format!("json/scan-config_{file_stem}.json"));
16267
16268 let template = ResultTemplate {
16269 version: env!("CARGO_PKG_VERSION"),
16270 report_title: run.effective_configuration.reporting.report_title.clone(),
16271 project_path: project_path.clone(),
16272 output_dir: display_path(run_dir),
16273 run_id: run_id.clone(),
16274 run_id_short: run_id
16275 .split('-')
16276 .next_back()
16277 .unwrap_or(run_id)
16278 .chars()
16279 .take(7)
16280 .collect(),
16281 files_analyzed,
16282 files_skipped,
16283 physical_lines: totals.physical_lines,
16284 code_lines: totals.code_lines,
16285 comment_lines: totals.comment_lines,
16286 blank_lines: totals.blank_lines,
16287 mixed_lines: totals.mixed_lines,
16288 functions: totals.functions,
16289 classes: totals.classes,
16290 variables: totals.variables,
16291 imports: totals.imports,
16292 html_url: make_rel(html_path),
16293 pdf_url: make_rel(pdf_path),
16294 json_url: make_rel(json_path),
16295 html_download_url: make_rel(html_path),
16296 pdf_download_url: make_rel(pdf_path),
16297 json_download_url: make_rel(json_path),
16298 html_path: html_path.map(display_path),
16299 json_path: json_path.map(display_path),
16300 prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
16301 prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
16302 prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
16303 prev_fa_str,
16304 prev_fs_str,
16305 prev_pl_str,
16306 prev_cl_str,
16307 prev_cml_str,
16308 prev_bl_str,
16309 delta_fa_str,
16310 delta_fa_class,
16311 delta_fs_str,
16312 delta_fs_class,
16313 delta_pl_str,
16314 delta_pl_class,
16315 delta_cl_str,
16316 delta_cl_class,
16317 delta_cml_str,
16318 delta_cml_class,
16319 delta_bl_str,
16320 delta_bl_class,
16321 delta_lines_added,
16322 delta_lines_removed,
16323 delta_lines_net_str,
16324 delta_lines_net_class,
16325 delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
16326 delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
16327 delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
16328 delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
16329 delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
16330 git_branch: run.git_branch.clone(),
16331 git_branch_url,
16332 git_commit: run.git_commit_short.clone(),
16333 git_commit_long: run.git_commit_long.clone(),
16334 git_author: run.git_commit_author.clone(),
16335 git_commit_url,
16336 scan_performed_by,
16337 scan_time_display: fmt_la_time_meta(run.tool.timestamp_utc),
16338 scan_time_utc_ms: run.tool.timestamp_utc.timestamp_millis(),
16339 os_display: format!(
16340 "{} / {}",
16341 run.environment.operating_system, run.environment.architecture
16342 ),
16343 test_count: run.summary_totals.test_count,
16344 test_assertion_count: run.summary_totals.test_assertion_count,
16345 current_scan_number: prev_scan_count + 1,
16346 prev_scan_count,
16347 submodule_rows,
16348 pdf_generating: false,
16349 scan_config_url: scan_config_rel,
16350 lang_chart_json,
16351 scatter_chart_json: build_scatter_chart_json(run),
16352 semantic_chart_json: build_semantic_chart_json(run),
16353 submodule_chart_json: build_submodule_chart_json(run),
16354 has_submodule_data: !run.submodule_summaries.is_empty(),
16355 has_semantic_data: run
16356 .totals_by_language
16357 .iter()
16358 .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
16359 csp_nonce: String::new(),
16360 confluence_configured: false,
16361 server_mode: false,
16362 report_header_footer: run
16363 .effective_configuration
16364 .reporting
16365 .report_header_footer
16366 .clone(),
16367 is_offline: true,
16368 cyclomatic_complexity: run.summary_totals.cyclomatic_complexity,
16369 lsloc: run.summary_totals.lsloc,
16370 uloc: run.uloc,
16371 dryness_pct_str: run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}")),
16372 duplicate_group_count: run.duplicate_groups.len(),
16373 has_cocomo: run.cocomo.is_some(),
16374 cocomo_effort_str: run
16375 .cocomo
16376 .as_ref()
16377 .map_or(String::new(), |c| format!("{:.2}", c.effort_person_months)),
16378 cocomo_duration_str: run
16379 .cocomo
16380 .as_ref()
16381 .map_or(String::new(), |c| format!("{:.2}", c.duration_months)),
16382 cocomo_staff_str: run
16383 .cocomo
16384 .as_ref()
16385 .map_or(String::new(), |c| format!("{:.2}", c.avg_staff)),
16386 cocomo_ksloc_str: run
16387 .cocomo
16388 .as_ref()
16389 .map_or(String::new(), |c| format!("{:.2}", c.ksloc)),
16390 cocomo_mode_label: run.cocomo.as_ref().map_or_else(
16391 || "Organic".to_string(),
16392 |c| cocomo_mode_label(c.mode).to_string(),
16393 ),
16394 cocomo_mode_tooltip: run
16395 .cocomo
16396 .as_ref()
16397 .map_or(String::new(), |c| cocomo_mode_tooltip(c.mode).to_string()),
16398 complexity_alert: 0,
16399 has_coverage_data: run.summary_totals.coverage_lines_found > 0,
16400 cov_line_pct: cov_pct_str(
16401 run.summary_totals.coverage_lines_hit,
16402 run.summary_totals.coverage_lines_found,
16403 ),
16404 cov_fn_pct: cov_pct_str(
16405 run.summary_totals.coverage_functions_hit,
16406 run.summary_totals.coverage_functions_found,
16407 ),
16408 cov_branch_pct: cov_pct_str(
16409 run.summary_totals.coverage_branches_hit,
16410 run.summary_totals.coverage_branches_found,
16411 ),
16412 cov_lines_summary: cov_lines_summary_str(
16413 run.summary_totals.coverage_lines_hit,
16414 run.summary_totals.coverage_lines_found,
16415 ),
16416 };
16417
16418 if let Ok(html) = template.render() {
16419 let html = inline_offline_logos(&html);
16423 let index_path = run_dir.join("index.html");
16424 if let Err(e) = fs::write(&index_path, html) {
16425 eprintln!("[oxide-sloc] index.html write failed (non-fatal): {e:#}");
16426 }
16427 }
16428}
16429
16430fn inline_offline_logos(html: &str) -> String {
16434 use base64::Engine;
16435 let text_uri = format!(
16436 "data:image/png;base64,{}",
16437 base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_TEXT)
16438 );
16439 let small_uri = format!(
16440 "data:image/png;base64,{}",
16441 base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_SMALL)
16442 );
16443 html.replace("/images/logo/logo-text.png", &text_uri)
16444 .replace("/images/logo/small-logo.png", &small_uri)
16445}
16446
16447fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
16450 if let Some(found) = find_scan_config_in_dir_flat(&dir.join("json")) {
16452 return Some(found);
16453 }
16454 find_scan_config_in_dir_flat(dir)
16456}
16457
16458fn find_scan_config_in_dir_flat(dir: &Path) -> Option<PathBuf> {
16459 let exact = dir.join("scan-config.json");
16460 if exact.exists() {
16461 return Some(exact);
16462 }
16463 fs::read_dir(dir).ok().and_then(|entries| {
16464 entries
16465 .filter_map(std::result::Result::ok)
16466 .find(|e| {
16467 let name = e.file_name();
16468 let name = name.to_string_lossy();
16469 name.starts_with("scan-config") && name.ends_with(".json")
16470 })
16471 .map(|e| e.path())
16472 })
16473}
16474
16475#[derive(Deserialize)]
16480struct ExportPdfRequest {
16481 html: String,
16482 #[serde(default)]
16483 filename: Option<String>,
16484}
16485
16486async fn export_pdf_handler(Json(body): Json<ExportPdfRequest>) -> impl IntoResponse {
16487 let html_content = body.html;
16488 let filename = body.filename.unwrap_or_else(|| "report.pdf".to_string());
16489 if html_content.is_empty() {
16490 return (StatusCode::BAD_REQUEST, "Missing html field").into_response();
16491 }
16492 let tmp_dir = std::env::temp_dir();
16494 let html_path = tmp_dir.join(format!(
16495 "sloc-export-{}.html",
16496 uuid::Uuid::new_v4().simple()
16497 ));
16498 let pdf_path = tmp_dir.join(format!("sloc-export-{}.pdf", uuid::Uuid::new_v4().simple()));
16499 if let Err(e) = std::fs::write(&html_path, &html_content) {
16500 return (
16501 StatusCode::INTERNAL_SERVER_ERROR,
16502 format!("Failed to write temp HTML: {e}"),
16503 )
16504 .into_response();
16505 }
16506 let pdf_result = write_pdf_from_html(&html_path, &pdf_path);
16507 let _ = std::fs::remove_file(&html_path);
16508 if let Err(e) = pdf_result {
16509 let _ = std::fs::remove_file(&pdf_path);
16510 return (
16511 StatusCode::INTERNAL_SERVER_ERROR,
16512 format!("PDF generation failed: {e}"),
16513 )
16514 .into_response();
16515 }
16516 let pdf_bytes = match std::fs::read(&pdf_path) {
16517 Ok(b) => b,
16518 Err(e) => {
16519 let _ = std::fs::remove_file(&pdf_path);
16520 return (
16521 StatusCode::INTERNAL_SERVER_ERROR,
16522 format!("Failed to read PDF: {e}"),
16523 )
16524 .into_response();
16525 }
16526 };
16527 let _ = std::fs::remove_file(&pdf_path);
16528 let safe_name: String = filename
16529 .chars()
16530 .map(|c| {
16531 if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
16532 c
16533 } else {
16534 '_'
16535 }
16536 })
16537 .collect();
16538 let disposition = format!("attachment; filename=\"{safe_name}\"");
16539 (
16540 [
16541 (header::CONTENT_TYPE, "application/pdf".to_string()),
16542 (header::CONTENT_DISPOSITION, disposition),
16543 ],
16544 pdf_bytes,
16545 )
16546 .into_response()
16547}
16548
16549async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
16550 let toml_str = match toml::to_string_pretty(&state.base_config) {
16551 Ok(s) => s,
16552 Err(e) => {
16553 return (
16554 StatusCode::INTERNAL_SERVER_ERROR,
16555 format!("serialization error: {e}"),
16556 )
16557 .into_response();
16558 }
16559 };
16560 (
16561 [
16562 (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
16563 (
16564 header::CONTENT_DISPOSITION,
16565 "attachment; filename=\".oxide-sloc.toml\"",
16566 ),
16567 ],
16568 toml_str,
16569 )
16570 .into_response()
16571}
16572
16573#[derive(Serialize)]
16574struct OkResponse {
16575 ok: bool,
16576}
16577
16578#[derive(Serialize)]
16579struct SaveProfileResponse {
16580 ok: bool,
16581 id: String,
16582}
16583
16584#[derive(Serialize)]
16585struct ProfileListResponse {
16586 profiles: Vec<ScanProfile>,
16587}
16588
16589#[derive(Serialize)]
16590struct ImportConfigResponse {
16591 ok: bool,
16592 config: sloc_config::AppConfig,
16593}
16594
16595#[derive(Deserialize)]
16596struct ImportConfigBody {
16597 toml: String,
16598}
16599
16600async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
16601 match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
16602 Ok(config) => {
16603 if let Err(e) = config.validate() {
16604 return error::unprocessable_entity(&e.to_string());
16605 }
16606 Json(ImportConfigResponse { ok: true, config }).into_response()
16607 }
16608 Err(e) => error::bad_request(&format!("TOML parse error: {e}")),
16609 }
16610}
16611
16612async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
16615 let store = state.scan_profiles.lock().await;
16616 Json(ProfileListResponse {
16617 profiles: store.profiles.clone(),
16618 })
16619}
16620
16621#[derive(Deserialize)]
16622struct SaveScanProfileBody {
16623 name: String,
16624 params: serde_json::Value,
16625}
16626
16627async fn api_save_scan_profile(
16628 State(state): State<AppState>,
16629 Json(body): Json<SaveScanProfileBody>,
16630) -> impl IntoResponse {
16631 if body.name.trim().is_empty() {
16632 return error::bad_request("name must not be empty");
16633 }
16634
16635 let id = uuid::Uuid::new_v4().to_string();
16636 let profile = ScanProfile {
16637 id: id.clone(),
16638 name: body.name.trim().to_string(),
16639 created_at: chrono::Utc::now().to_rfc3339(),
16640 params: body.params,
16641 };
16642
16643 let mut store = state.scan_profiles.lock().await;
16644 store.profiles.push(profile);
16645 if let Err(e) = store.save(&state.scan_profiles_path) {
16646 tracing::warn!("failed to persist scan profiles: {e}");
16647 }
16648 drop(store);
16649
16650 (
16651 StatusCode::CREATED,
16652 Json(SaveProfileResponse { ok: true, id }),
16653 )
16654 .into_response()
16655}
16656
16657async fn api_delete_scan_profile(
16658 State(state): State<AppState>,
16659 AxumPath(id): AxumPath<String>,
16660) -> impl IntoResponse {
16661 let mut store = state.scan_profiles.lock().await;
16662 let before = store.profiles.len();
16663 store.profiles.retain(|p| p.id != id);
16664 if store.profiles.len() == before {
16665 drop(store);
16666 return error::not_found("profile not found");
16667 }
16668 if let Err(e) = store.save(&state.scan_profiles_path) {
16669 tracing::warn!("failed to persist scan profiles: {e}");
16670 }
16671 drop(store);
16672 Json(OkResponse { ok: true }).into_response()
16673}
16674
16675fn resolve_output_root(raw: Option<&str>) -> PathBuf {
16676 let value = raw.unwrap_or("out/web").trim();
16677 let path = if value.is_empty() {
16678 PathBuf::from("out/web")
16679 } else {
16680 PathBuf::from(value)
16681 };
16682
16683 if path.is_absolute() {
16684 path
16685 } else {
16686 workspace_root().join(path)
16687 }
16688}
16689
16690fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
16692 std::env::var("SLOC_GIT_CLONES_DIR")
16693 .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
16694}
16695
16696pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
16699 let safe: String = repo_url
16700 .chars()
16701 .map(|c| {
16702 if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
16703 c
16704 } else {
16705 '_'
16706 }
16707 })
16708 .take(80)
16709 .collect();
16710 clones_dir.join(safe)
16711}
16712
16713pub(crate) fn scan_path_to_artifacts(
16716 scan_path: &Path,
16717 base_config: &AppConfig,
16718 label: &str,
16719) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
16720 let mut config = base_config.clone();
16721 config.discovery.root_paths = vec![scan_path.to_path_buf()];
16722 label.clone_into(&mut config.reporting.report_title);
16723 let run = analyze(&config, "git", None, None)?;
16724 let html = render_html(&run)?;
16725 let run_id = run.tool.run_id.clone();
16726 let project_label = sanitize_project_label(label);
16727 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
16728 let file_stem = {
16729 let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
16730 if commit.is_empty() {
16731 project_label
16732 } else {
16733 format!("{project_label}_{commit}")
16734 }
16735 };
16736 let (artifacts, _pending_pdf) = persist_run_artifacts(
16737 &run,
16738 &html,
16739 &output_dir,
16740 label,
16741 &file_stem,
16742 RunResultContext::default(),
16743 )?;
16744 Ok((run_id, artifacts, run))
16745}
16746
16747async fn restart_poll_schedules(state: &AppState) {
16749 let store = state.schedules.lock().await;
16750 let poll_schedules: Vec<_> = store
16751 .schedules
16752 .iter()
16753 .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
16754 .cloned()
16755 .collect();
16756 drop(store);
16757 for schedule in poll_schedules {
16758 let interval = schedule.interval_secs.unwrap_or(300);
16759 let st = state.clone();
16760 tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
16761 }
16762}
16763
16764async fn warn_insecure_gitlab_webhooks(state: &AppState) {
16770 if state.tls_enabled {
16771 return;
16772 }
16773 let store = state.schedules.lock().await;
16774 let has_gitlab_webhook = store.schedules.iter().any(|s| {
16775 s.kind == sloc_git::ScanScheduleKind::Webhook
16776 && s.provider == sloc_git::ScanScheduleProvider::GitLab
16777 });
16778 drop(store);
16779 if has_gitlab_webhook {
16780 tracing::warn!(
16781 "GitLab webhook schedule(s) configured but native TLS is not enabled. \
16782 GitLab sends its webhook token as a plaintext X-Gitlab-Token header; \
16783 terminate TLS here (SLOC_TLS_CERT/SLOC_TLS_KEY) or at an upstream reverse \
16784 proxy so the token is not exposed in cleartext."
16785 );
16786 }
16787}
16788
16789fn split_patterns(raw: Option<&str>) -> Vec<String> {
16790 raw.unwrap_or("")
16791 .lines()
16792 .flat_map(|line| line.split(','))
16793 .map(str::trim)
16794 .filter(|part| !part.is_empty())
16795 .map(ToOwned::to_owned)
16796 .collect()
16797}
16798
16799#[must_use]
16800pub fn build_sub_run(
16801 parent: &AnalysisRun,
16802 sub: &sloc_core::SubmoduleSummary,
16803 parent_path: &str,
16804) -> AnalysisRun {
16805 let sub_files: Vec<_> = parent
16806 .per_file_records
16807 .iter()
16808 .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
16809 .cloned()
16810 .collect();
16811 let mut config = parent.effective_configuration.clone();
16812 config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
16813
16814 let mut functions = 0u64;
16816 let mut classes = 0u64;
16817 let mut variables = 0u64;
16818 let mut imports = 0u64;
16819 let mut test_count = 0u64;
16820 let mut test_assertion_count = 0u64;
16821 let mut test_suite_count = 0u64;
16822 let mut mixed_lines_separate = 0u64;
16823 let mut coverage_lines_found = 0u64;
16824 let mut coverage_lines_hit = 0u64;
16825 let mut coverage_functions_found = 0u64;
16826 let mut coverage_functions_hit = 0u64;
16827 let mut coverage_branches_found = 0u64;
16828 let mut coverage_branches_hit = 0u64;
16829 for r in &sub_files {
16830 functions += r.raw_line_categories.functions;
16831 classes += r.raw_line_categories.classes;
16832 variables += r.raw_line_categories.variables;
16833 imports += r.raw_line_categories.imports;
16834 test_count += r.raw_line_categories.test_count;
16835 test_assertion_count += r.raw_line_categories.test_assertion_count;
16836 test_suite_count += r.raw_line_categories.test_suite_count;
16837 mixed_lines_separate += r.effective_counts.mixed_lines_separate;
16838 if let Some(cov) = &r.coverage {
16839 coverage_lines_found += u64::from(cov.lines_found);
16840 coverage_lines_hit += u64::from(cov.lines_hit);
16841 coverage_functions_found += u64::from(cov.functions_found);
16842 coverage_functions_hit += u64::from(cov.functions_hit);
16843 coverage_branches_found += u64::from(cov.branches_found);
16844 coverage_branches_hit += u64::from(cov.branches_hit);
16845 }
16846 }
16847
16848 AnalysisRun {
16849 tool: parent.tool.clone(),
16850 environment: parent.environment.clone(),
16851 effective_configuration: config,
16852 input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
16853 summary_totals: SummaryTotals {
16854 files_considered: sub.files_analyzed,
16855 files_analyzed: sub.files_analyzed,
16856 files_skipped: 0,
16857 total_physical_lines: sub.total_physical_lines,
16858 code_lines: sub.code_lines,
16859 comment_lines: sub.comment_lines,
16860 blank_lines: sub.blank_lines,
16861 mixed_lines_separate,
16862 functions,
16863 classes,
16864 variables,
16865 imports,
16866 test_count,
16867 test_assertion_count,
16868 test_suite_count,
16869 coverage_lines_found,
16870 coverage_lines_hit,
16871 coverage_functions_found,
16872 coverage_functions_hit,
16873 coverage_branches_found,
16874 coverage_branches_hit,
16875 cyclomatic_complexity: 0,
16876 lsloc: None,
16877 ..Default::default()
16878 },
16879 totals_by_language: sub.language_summaries.clone(),
16880 per_file_records: sub_files,
16881 skipped_file_records: vec![],
16882 warnings: vec![],
16883 submodule_summaries: vec![],
16884 git_commit_short: sub.git_commit_short.clone(),
16885 git_commit_long: sub.git_commit_long.clone(),
16886 git_branch: sub.git_branch.clone(),
16887 git_commit_author: sub.git_commit_author.clone(),
16888 git_commit_date: sub.git_commit_date.clone(),
16889 git_tags: None,
16890 git_nearest_tag: None,
16891 git_remote_url: sub.git_remote_url.clone(),
16892 style_summary: None,
16893 cocomo: None,
16894 uloc: 0,
16895 dryness_pct: None,
16896 duplicate_groups: vec![],
16897 duplicates_excluded: 0,
16898 }
16899}
16900
16901#[must_use]
16902pub fn sanitize_project_label(raw: &str) -> String {
16903 let candidate = raw
16906 .split(['/', '\\'])
16907 .rfind(|s| !s.is_empty())
16908 .unwrap_or("project");
16909
16910 let mut value = String::with_capacity(candidate.len());
16911 for ch in candidate.chars() {
16912 if ch.is_ascii_alphanumeric() {
16913 value.push(ch.to_ascii_lowercase());
16914 } else {
16915 value.push('-');
16916 }
16917 }
16918
16919 let compact = value.trim_matches('-').to_string();
16920 if compact.is_empty() {
16921 "project".to_string()
16922 } else {
16923 compact
16924 }
16925}
16926
16927fn strip_unc_prefix(path: PathBuf) -> PathBuf {
16930 let s = path.to_string_lossy();
16931 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
16932 return PathBuf::from(format!(r"\\{rest}"));
16933 }
16934 if let Some(rest) = s.strip_prefix(r"\\?\") {
16935 return PathBuf::from(rest);
16936 }
16937 path
16938}
16939
16940fn remote_to_commit_url(remote: &str, sha: &str) -> Option<String> {
16943 let base = if let Some(rest) = remote.strip_prefix("git@") {
16944 let (host, path) = rest.split_once(':')?;
16945 format!("https://{}/{}", host, path.trim_end_matches(".git"))
16946 } else if remote.starts_with("https://") || remote.starts_with("http://") {
16947 remote
16948 .trim_end_matches('/')
16949 .trim_end_matches(".git")
16950 .to_owned()
16951 } else {
16952 return None;
16953 };
16954 let base = base.trim_end_matches('/');
16955 if base.contains("gitlab.com") || base.contains("gitlab.") {
16957 Some(format!("{base}/-/commit/{sha}"))
16958 } else if base.contains("bitbucket.org") {
16959 Some(format!("{base}/commits/{sha}"))
16960 } else {
16961 Some(format!("{base}/commit/{sha}"))
16962 }
16963}
16964
16965fn remote_to_branch_url(remote: &str, branch: &str) -> Option<String> {
16968 let base = if let Some(rest) = remote.strip_prefix("git@") {
16969 let (host, path) = rest.split_once(':')?;
16970 format!("https://{}/{}", host, path.trim_end_matches(".git"))
16971 } else if remote.starts_with("https://") || remote.starts_with("http://") {
16972 remote
16973 .trim_end_matches('/')
16974 .trim_end_matches(".git")
16975 .to_owned()
16976 } else {
16977 return None;
16978 };
16979 let base = base.trim_end_matches('/');
16980 if base.contains("gitlab.com") || base.contains("gitlab.") {
16981 Some(format!("{base}/-/tree/{branch}"))
16982 } else {
16983 Some(format!("{base}/tree/{branch}"))
16984 }
16985}
16986
16987fn display_path(path: &Path) -> String {
16988 let s = path.to_string_lossy();
16989 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
16994 return format!(r"\\{rest}");
16995 }
16996 if let Some(rest) = s.strip_prefix(r"\\?\") {
16997 return rest.to_owned();
16998 }
16999 s.into_owned()
17000}
17001
17002fn sanitize_path_str(s: &str) -> String {
17003 if let Some(rest) = s.strip_prefix("//?/UNC/") {
17007 return format!("//{rest}");
17008 }
17009 if let Some(rest) = s.strip_prefix("//?/") {
17010 return rest.to_owned();
17011 }
17012 display_path(Path::new(s))
17013}
17014
17015fn workspace_root() -> PathBuf {
17016 if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
17018 let p = PathBuf::from(root);
17019 if p.is_dir() {
17020 return p;
17021 }
17022 }
17023
17024 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
17027}
17028
17029fn make_git_label(repo: &str, ref_name: &str) -> String {
17031 if repo.is_empty() || ref_name.is_empty() {
17032 return String::new();
17033 }
17034 let base = repo
17035 .trim_end_matches('/')
17036 .trim_end_matches(".git")
17037 .rsplit('/')
17038 .next()
17039 .unwrap_or("repo");
17040 let ref_safe: String = ref_name
17041 .chars()
17042 .map(|c| {
17043 if c.is_alphanumeric() || c == '-' || c == '.' {
17044 c
17045 } else {
17046 '_'
17047 }
17048 })
17049 .collect();
17050 format!("{base}_at_{ref_safe}_sloc")
17051}
17052
17053fn desktop_dir() -> PathBuf {
17055 if let Ok(profile) = std::env::var("USERPROFILE") {
17056 let p = PathBuf::from(profile).join("Desktop");
17057 if p.exists() {
17058 return p;
17059 }
17060 }
17061 if let Ok(home) = std::env::var("HOME") {
17062 let p = PathBuf::from(home).join("Desktop");
17063 if p.exists() {
17064 return p;
17065 }
17066 }
17067 workspace_root().join("out").join("web")
17068}
17069
17070fn resolve_input_path(raw: &str) -> PathBuf {
17071 let trimmed = raw.trim();
17072 if trimmed.is_empty() {
17073 return workspace_root().join("samples").join("basic");
17074 }
17075
17076 let candidate = PathBuf::from(trimmed);
17077 let resolved = if candidate.is_absolute() {
17078 candidate
17079 } else {
17080 let rooted = workspace_root().join(&candidate);
17081 if rooted.exists() {
17082 rooted
17083 } else {
17084 workspace_root().join(candidate)
17085 }
17086 };
17087
17088 let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
17091 PathBuf::from(display_path(&canonical))
17092}
17093
17094fn dir_size_bytes(path: &Path) -> u64 {
17095 let mut total = 0u64;
17096 if let Ok(rd) = fs::read_dir(path) {
17097 for entry in rd.filter_map(Result::ok) {
17098 let p = entry.path();
17099 if p.is_file() {
17100 if let Ok(meta) = p.metadata() {
17101 total += meta.len();
17102 }
17103 } else if p.is_dir() {
17104 total += dir_size_bytes(&p);
17105 }
17106 }
17107 }
17108 total
17109}
17110
17111#[allow(clippy::cast_precision_loss)] fn format_dir_size(bytes: u64) -> String {
17113 if bytes >= 1_073_741_824 {
17114 format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
17115 } else if bytes >= 1_048_576 {
17116 format!("{:.1} MB", bytes as f64 / 1_048_576.0)
17117 } else if bytes >= 1_024 {
17118 format!("{:.0} KB", bytes as f64 / 1_024.0)
17119 } else {
17120 format!("{bytes} B")
17121 }
17122}
17123
17124fn render_submodule_chips(
17125 root: &Path,
17126 submodules: &[(String, std::path::PathBuf)],
17127 out: &mut String,
17128) {
17129 use std::fmt::Write as _;
17130 let count = submodules.len();
17131 out.push_str(r#"<div class="submodule-preview-strip">"#);
17132 write!(
17133 out,
17134 r#"<div class="submodule-preview-label"><svg viewBox="0 0 24 24" aria-hidden="true"><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><strong>{count}</strong> git submodule{} detected</div>"#,
17135 if count == 1 { "" } else { "s" }
17136 )
17137 .ok();
17138 out.push_str(r#"<div class="submodule-preview-chips">"#);
17139 for (sub_name, sub_rel_path) in submodules {
17140 let sub_abs = root.join(sub_rel_path);
17141 let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
17142 let mut sub_stats = PreviewStats::default();
17143 let mut sub_rows: Vec<PreviewRow> = Vec::new();
17144 let mut sub_langs: Vec<&'static str> = Vec::new();
17145 let mut sub_budget = PreviewBudget {
17146 shown: 0,
17147 max_entries: 2000,
17148 max_depth: 9,
17149 };
17150 let mut sub_next_id = 1usize;
17151 let _ = collect_preview_rows(
17152 &sub_abs,
17153 &sub_abs,
17154 0,
17155 None,
17156 &mut sub_next_id,
17157 &mut sub_budget,
17158 &mut sub_stats,
17159 &mut sub_rows,
17160 &mut sub_langs,
17161 &[],
17162 &[],
17163 );
17164 let stats_json = format!(
17165 r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
17166 sub_stats.directories,
17167 sub_stats.files,
17168 sub_stats.supported,
17169 sub_stats.skipped,
17170 sub_stats.unsupported
17171 );
17172 write!(
17173 out,
17174 r#"<button type="button" class="submodule-preview-chip" data-sub-name="{}" data-sub-path="{}" data-size="{}" data-sub-stats="{}">{}<span class="submodule-chip-tooltip">Size: {}</span></button>"#,
17175 escape_html(sub_name),
17176 escape_html(&sub_rel_path.to_string_lossy()),
17177 escape_html(&sub_size),
17178 escape_html(&stats_json),
17179 escape_html(sub_name),
17180 escape_html(&sub_size),
17181 )
17182 .ok();
17183 }
17184 out.push_str(
17185 r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">↑ Base repo</button>"#,
17186 );
17187 out.push_str(r"</div>");
17188}
17189
17190fn render_multi_repo_warning(root: &Path, layout: &sloc_core::RepositoryLayout, out: &mut String) {
17194 use std::fmt::Write as _;
17195 const MAX_LISTED: usize = 5;
17196 let total = layout.nested_repos.len();
17197
17198 out.push_str(r#"<div class="preview-warning" data-multi-repo="1">"#);
17199 if layout.root_is_repo {
17200 write!(
17201 out,
17202 r"<strong>Nested repositories detected</strong><p>This repository contains {total} nested git {} that are not registered submodules. Their files will be counted as part of this project. Submodules are fine — but if these are unrelated repositories, scan one repository at a time. Pick a repository to scan on its own:</p>",
17203 if total == 1 { "repository" } else { "repositories" }
17204 )
17205 .ok();
17206 } else {
17207 write!(
17208 out,
17209 r"<strong>Multiple repositories detected</strong><p>This folder contains {total} independent git repositories. oxide-sloc analyzes one repository at a time — git metrics and totals are only meaningful when the root is a single repository (submodules are fine). Pick one repository as the scan root:</p>"
17210 )
17211 .ok();
17212 }
17213
17214 out.push_str(r#"<div class="repo-pick-row">"#);
17215 for rel in layout.nested_repos.iter().take(MAX_LISTED) {
17216 let abs = root.join(rel);
17217 let abs_display = display_path(&abs);
17218 let label = rel.to_string_lossy().replace('\\', "/");
17219 write!(
17220 out,
17221 r#"<button type="button" class="repo-pick" data-repo-path="{}">{}</button>"#,
17222 escape_html(&abs_display),
17223 escape_html(&label)
17224 )
17225 .ok();
17226 }
17227 if total > MAX_LISTED {
17228 write!(
17229 out,
17230 r#"<span class="repo-pick-more">and {} more</span>"#,
17231 total - MAX_LISTED
17232 )
17233 .ok();
17234 }
17235 out.push_str(r"</div>");
17236
17237 out.push_str(r#"<label class="multi-repo-ack-label"><input type="checkbox" class="multi-repo-ack" /> I understand — scan this folder anyway</label>"#);
17238 out.push_str(r"</div>");
17239}
17240
17241fn render_language_pills_row(languages: &[&str], out: &mut String) {
17242 use std::fmt::Write as _;
17243 if languages.is_empty() {
17244 out.push_str(
17245 r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
17246 );
17247 return;
17248 }
17249 out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
17250 for language in languages {
17251 if let Some(icon) = language_icon_file(language) {
17252 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();
17253 } else if let Some(svg) = language_inline_svg(language) {
17254 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();
17255 } else {
17256 write!(
17257 out,
17258 r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
17259 escape_html(&language.to_ascii_lowercase()),
17260 escape_html(language)
17261 )
17262 .ok();
17263 }
17264 }
17265}
17266
17267#[allow(clippy::too_many_lines)]
17268fn build_preview_html(
17269 root: &Path,
17270 include_patterns: &[String],
17271 exclude_patterns: &[String],
17272) -> Result<String> {
17273 if !root.exists() {
17274 return Ok(format!(
17275 r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
17276 escape_html(&display_path(root))
17277 ));
17278 }
17279
17280 let _selected = display_path(root);
17281 let mut stats = PreviewStats::default();
17282 let mut rows = Vec::new();
17283 let mut languages = Vec::new();
17284 let mut budget = PreviewBudget {
17285 shown: 0,
17286 max_entries: 600,
17287 max_depth: 9,
17288 };
17289 let mut next_row_id = 1usize;
17290
17291 let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
17292 || root.to_string_lossy().into_owned(),
17293 std::string::ToString::to_string,
17294 );
17295 let root_modified = root
17296 .metadata()
17297 .ok()
17298 .and_then(|meta| meta.modified().ok())
17299 .map_or_else(|| "-".to_string(), format_system_time);
17300
17301 rows.push(PreviewRow {
17302 row_id: 0,
17303 parent_row_id: None,
17304 depth: 0,
17305 name: format!("{root_name}/"),
17306 kind: PreviewKind::Dir,
17307 is_dir: true,
17308 language: None,
17309 modified: root_modified,
17310 type_label: "Directory".to_string(),
17311 });
17312 collect_preview_rows(
17313 root,
17314 root,
17315 0,
17316 Some(0),
17317 &mut next_row_id,
17318 &mut budget,
17319 &mut stats,
17320 &mut rows,
17321 &mut languages,
17322 include_patterns,
17323 exclude_patterns,
17324 )?;
17325
17326 let root_size = format_dir_size(dir_size_bytes(root));
17327
17328 let mut out = String::new();
17329 write!(
17330 out,
17331 r#"<div class="explorer-wrap" data-project-size="{}">"#,
17332 escape_html(&root_size)
17333 )
17334 .ok();
17335 out.push_str(r#"<div class="explorer-toolbar compact">"#);
17336 out.push_str(r#"<div class="explorer-title-group">"#);
17337 out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
17338 out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
17339 out.push_str(r"</div></div>");
17340
17341 out.push_str(r#"<div class="scope-stats">"#);
17342 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();
17343 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();
17344 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();
17345 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();
17346 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();
17347 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>"#);
17348 out.push_str(r"</div>");
17349
17350 let submodules = sloc_core::detect_submodules(root);
17351 if !submodules.is_empty() {
17352 render_submodule_chips(root, &submodules, &mut out);
17353 }
17354
17355 let repo_layout = sloc_core::detect_repository_layout(root);
17356 if repo_layout.has_multiple_repos() {
17357 render_multi_repo_warning(root, &repo_layout, &mut out);
17358 }
17359
17360 out.push_str(r#"<div class="scope-info-row">"#);
17361 out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
17362 render_language_pills_row(&languages, &mut out);
17363 out.push_str(r"</div></div>");
17364 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>"#);
17365 out.push_str(r"</div>");
17366
17367 out.push_str(r#"<div class="file-explorer-shell">"#);
17368 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>"#);
17369 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>"#);
17370 out.push_str(r#"<div class="file-explorer-tree">"#);
17371 for row in rows {
17372 let status_label = row.kind.label();
17373 let lang_attr = row.language.unwrap_or("");
17374 let toggle_html = if row.is_dir {
17375 r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
17376 .to_string()
17377 } else {
17378 r#"<span class="tree-bullet">•</span>"#.to_string()
17379 };
17380 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();
17381 }
17382 if budget.shown >= budget.max_entries {
17383 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>"#);
17384 }
17385 out.push_str(r"</div></div></div>");
17386
17387 Ok(out)
17388}
17389
17390#[derive(Default)]
17391struct PreviewStats {
17392 directories: usize,
17393 files: usize,
17394 supported: usize,
17395 skipped: usize,
17396 unsupported: usize,
17397}
17398
17399struct PreviewRow {
17400 row_id: usize,
17401 parent_row_id: Option<usize>,
17402 depth: usize,
17403 name: String,
17404 kind: PreviewKind,
17405 is_dir: bool,
17406 language: Option<&'static str>,
17407 modified: String,
17408 type_label: String,
17409}
17410
17411#[derive(Copy, Clone)]
17412enum PreviewKind {
17413 Dir,
17414 Supported,
17415 Skipped,
17416 Unsupported,
17417}
17418
17419impl PreviewKind {
17420 const fn filter_key(self) -> &'static str {
17421 match self {
17422 Self::Dir => "dir",
17423 Self::Supported => "supported",
17424 Self::Skipped => "skipped",
17425 Self::Unsupported => "unsupported",
17426 }
17427 }
17428
17429 const fn label(self) -> &'static str {
17430 match self {
17431 Self::Dir => "dir",
17432 Self::Supported => "supported",
17433 Self::Skipped => "skipped by policy",
17434 Self::Unsupported => "unsupported",
17435 }
17436 }
17437
17438 const fn badge_class(self) -> &'static str {
17439 match self {
17440 Self::Dir => "badge badge-dir",
17441 Self::Supported => "badge badge-scan",
17442 Self::Skipped => "badge badge-skip",
17443 Self::Unsupported => "badge badge-unsupported",
17444 }
17445 }
17446
17447 const fn node_class(self) -> &'static str {
17448 match self {
17449 Self::Dir => "tree-node-dir",
17450 Self::Supported => "tree-node-supported",
17451 Self::Skipped => "tree-node-skipped",
17452 Self::Unsupported => "tree-node-unsupported",
17453 }
17454 }
17455}
17456
17457struct PreviewBudget {
17458 shown: usize,
17459 max_entries: usize,
17460 max_depth: usize,
17461}
17462
17463#[allow(clippy::too_many_arguments)]
17466fn handle_preview_dir_entry(
17467 root: &Path,
17468 path: &Path,
17469 name: &str,
17470 modified: String,
17471 depth: usize,
17472 parent_row_id: Option<usize>,
17473 row_id: usize,
17474 next_row_id: &mut usize,
17475 budget: &mut PreviewBudget,
17476 stats: &mut PreviewStats,
17477 rows: &mut Vec<PreviewRow>,
17478 languages: &mut Vec<&'static str>,
17479 include_patterns: &[String],
17480 exclude_patterns: &[String],
17481) -> Result<()> {
17482 let relative = preview_relative_path(root, path);
17483 if should_skip_preview_directory(&relative, exclude_patterns) {
17484 return Ok(());
17485 }
17486 stats.directories += 1;
17487 rows.push(PreviewRow {
17488 row_id,
17489 parent_row_id,
17490 depth: depth + 1,
17491 name: format!("{name}/"),
17492 kind: PreviewKind::Dir,
17493 is_dir: true,
17494 language: None,
17495 modified,
17496 type_label: "Directory".to_string(),
17497 });
17498 budget.shown += 1;
17499 if !matches!(name, ".git" | "node_modules" | "target") {
17500 collect_preview_rows(
17501 root,
17502 path,
17503 depth + 1,
17504 Some(row_id),
17505 next_row_id,
17506 budget,
17507 stats,
17508 rows,
17509 languages,
17510 include_patterns,
17511 exclude_patterns,
17512 )?;
17513 }
17514 Ok(())
17515}
17516
17517#[allow(clippy::too_many_arguments)]
17519fn handle_preview_file_entry(
17520 root: &Path,
17521 path: &Path,
17522 name: &str,
17523 modified: String,
17524 depth: usize,
17525 parent_row_id: Option<usize>,
17526 row_id: usize,
17527 budget: &mut PreviewBudget,
17528 stats: &mut PreviewStats,
17529 rows: &mut Vec<PreviewRow>,
17530 languages: &mut Vec<&'static str>,
17531 include_patterns: &[String],
17532 exclude_patterns: &[String],
17533) {
17534 let relative = preview_relative_path(root, path);
17535 if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
17536 return;
17537 }
17538 stats.files += 1;
17539 let kind = classify_preview_file(name);
17540 match kind {
17541 PreviewKind::Supported => stats.supported += 1,
17542 PreviewKind::Skipped => stats.skipped += 1,
17543 PreviewKind::Unsupported => stats.unsupported += 1,
17544 PreviewKind::Dir => {}
17545 }
17546 let language = detect_language_name(name);
17547 if let Some(lang) = language {
17548 if !languages.contains(&lang) {
17549 languages.push(lang);
17550 }
17551 }
17552 rows.push(PreviewRow {
17553 row_id,
17554 parent_row_id,
17555 depth: depth + 1,
17556 name: name.to_owned(),
17557 kind,
17558 is_dir: false,
17559 language,
17560 modified,
17561 type_label: preview_type_label(name, language, kind),
17562 });
17563 budget.shown += 1;
17564}
17565
17566#[allow(clippy::too_many_arguments)]
17567#[allow(clippy::too_many_lines)]
17568fn collect_preview_rows(
17569 root: &Path,
17570 dir: &Path,
17571 depth: usize,
17572 parent_row_id: Option<usize>,
17573 next_row_id: &mut usize,
17574 budget: &mut PreviewBudget,
17575 stats: &mut PreviewStats,
17576 rows: &mut Vec<PreviewRow>,
17577 languages: &mut Vec<&'static str>,
17578 include_patterns: &[String],
17579 exclude_patterns: &[String],
17580) -> Result<()> {
17581 if depth >= budget.max_depth || budget.shown >= budget.max_entries {
17582 return Ok(());
17583 }
17584
17585 let mut entries = fs::read_dir(dir)
17586 .with_context(|| format!("failed to read directory {}", dir.display()))?
17587 .filter_map(std::result::Result::ok)
17588 .collect::<Vec<_>>();
17589 entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
17590
17591 for entry in entries {
17592 if budget.shown >= budget.max_entries {
17593 break;
17594 }
17595
17596 let path = entry.path();
17597 let name = entry.file_name().to_string_lossy().into_owned();
17598 let Ok(metadata) = entry.metadata() else {
17599 continue;
17600 };
17601 let row_id = *next_row_id;
17602 *next_row_id += 1;
17603 let modified = metadata
17604 .modified()
17605 .ok()
17606 .map_or_else(|| "-".to_string(), format_system_time);
17607
17608 if metadata.is_dir() {
17609 handle_preview_dir_entry(
17610 root,
17611 &path,
17612 &name,
17613 modified,
17614 depth,
17615 parent_row_id,
17616 row_id,
17617 next_row_id,
17618 budget,
17619 stats,
17620 rows,
17621 languages,
17622 include_patterns,
17623 exclude_patterns,
17624 )?;
17625 continue;
17626 }
17627
17628 if metadata.is_file() {
17629 handle_preview_file_entry(
17630 root,
17631 &path,
17632 &name,
17633 modified,
17634 depth,
17635 parent_row_id,
17636 row_id,
17637 budget,
17638 stats,
17639 rows,
17640 languages,
17641 include_patterns,
17642 exclude_patterns,
17643 );
17644 }
17645 }
17646
17647 Ok(())
17648}
17649
17650fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
17651 if let Some(language) = language {
17652 return format!("{language} source");
17653 }
17654 let lower = name.to_ascii_lowercase();
17655 let ext = Path::new(&lower)
17656 .extension()
17657 .and_then(|e| e.to_str())
17658 .unwrap_or("");
17659 match kind {
17660 PreviewKind::Skipped => {
17661 if lower.ends_with(".min.js") {
17662 "Minified asset".to_string()
17663 } else if [
17664 "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
17665 ]
17666 .contains(&ext)
17667 {
17668 "Binary or archive".to_string()
17669 } else {
17670 "Skipped file".to_string()
17671 }
17672 }
17673 PreviewKind::Unsupported => {
17674 if ext.is_empty() {
17675 "Unsupported file".to_string()
17676 } else {
17677 format!("{} file", ext.to_ascii_uppercase())
17678 }
17679 }
17680 PreviewKind::Supported => "Supported source".to_string(),
17681 PreviewKind::Dir => "Directory".to_string(),
17682 }
17683}
17684
17685fn format_system_time(time: SystemTime) -> String {
17686 #[allow(clippy::cast_possible_wrap)]
17687 let secs = match time.duration_since(UNIX_EPOCH) {
17688 Ok(duration) => duration.as_secs() as i64,
17689 Err(_) => return "-".to_string(),
17690 };
17691 let days = secs.div_euclid(86_400);
17692 let secs_of_day = secs.rem_euclid(86_400);
17693 let (year, month, day) = civil_from_days(days);
17694 let hour = secs_of_day / 3_600;
17695 let minute = (secs_of_day % 3_600) / 60;
17696 format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
17697}
17698
17699#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
17700fn civil_from_days(days: i64) -> (i32, u32, u32) {
17701 let z = days + 719_468;
17702 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
17703 let doe = z - era * 146_097;
17704 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
17705 let y = yoe + era * 400;
17706 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
17707 let mp = (5 * doy + 2) / 153;
17708 let d = doy - (153 * mp + 2) / 5 + 1;
17709 let m = mp + if mp < 10 { 3 } else { -9 };
17710 let year = y + i64::from(m <= 2);
17711 (year as i32, m as u32, d as u32)
17712}
17713
17714#[allow(clippy::case_sensitive_file_extension_comparisons)]
17717fn detect_language_name(name: &str) -> Option<&'static str> {
17718 let lower = name.to_ascii_lowercase();
17719 if lower.ends_with(".c") || lower.ends_with(".h") {
17720 Some("C")
17721 } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
17722 .iter()
17723 .any(|s| lower.ends_with(s))
17724 {
17725 Some("C++")
17726 } else if lower.ends_with(".cs") {
17727 Some("C#")
17728 } else if lower.ends_with(".py") {
17729 Some("Python")
17730 } else if lower.ends_with(".sh") {
17731 Some("Shell")
17732 } else if [".ps1", ".psm1", ".psd1"]
17733 .iter()
17734 .any(|s| lower.ends_with(s))
17735 {
17736 Some("PowerShell")
17737 } else {
17738 None
17739 }
17740}
17741
17742fn language_icon_file(language: &str) -> Option<&'static str> {
17743 match language {
17744 "C" => Some("c.png"),
17745 "C++" => Some("cpp.png"),
17746 "C#" => Some("c-sharp.png"),
17747 "Python" => Some("python.png"),
17748 "Shell" => Some("shell.png"),
17749 "PowerShell" => Some("powershell.png"),
17750 "JavaScript" => Some("java-script.png"),
17751 "HTML" => Some("html-5.png"),
17752 "Java" => Some("java.png"),
17753 "Visual Basic" => Some("visual-basic.png"),
17754 "Assembly" => Some("asm.png"),
17755 "Go" => Some("go.png"),
17756 "R" => Some("r.png"),
17757 "XML" => Some("xml.png"),
17758 "Groovy" => Some("groovy.png"),
17759 "Dockerfile" => Some("docker.png"),
17760 "Makefile" => Some("makefile.svg"),
17761 "Perl" => Some("perl.svg"),
17762 _ => None,
17763 }
17764}
17765
17766fn language_inline_svg(language: &str) -> Option<&'static str> {
17771 match language {
17772 "Rust" => Some(
17773 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>"##,
17774 ),
17775 "TypeScript" => Some(
17776 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>"##,
17777 ),
17778 _ => None,
17779 }
17780}
17781
17782#[allow(clippy::case_sensitive_file_extension_comparisons)]
17785fn classify_preview_file(name: &str) -> PreviewKind {
17786 let lower = name.to_ascii_lowercase();
17787
17788 let scannable = [
17789 ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
17790 ".psm1", ".psd1",
17791 ]
17792 .iter()
17793 .any(|suffix| lower.ends_with(suffix));
17794
17795 if scannable {
17796 PreviewKind::Supported
17797 } else if lower.ends_with(".min.js")
17798 || lower.ends_with(".lock")
17799 || lower.ends_with(".png")
17800 || lower.ends_with(".jpg")
17801 || lower.ends_with(".jpeg")
17802 || lower.ends_with(".gif")
17803 || lower.ends_with(".zip")
17804 || lower.ends_with(".pdf")
17805 || lower.ends_with(".pyc")
17806 || lower.ends_with(".xz")
17807 || lower.ends_with(".tar")
17808 || lower.ends_with(".gz")
17809 {
17810 PreviewKind::Skipped
17811 } else {
17812 PreviewKind::Unsupported
17813 }
17814}
17815
17816fn preview_relative_path(root: &Path, path: &Path) -> String {
17817 path.strip_prefix(root)
17818 .ok()
17819 .unwrap_or(path)
17820 .to_string_lossy()
17821 .replace('\\', "/")
17822 .trim_matches('/')
17823 .to_string()
17824}
17825
17826fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
17827 if relative.is_empty() {
17828 return false;
17829 }
17830
17831 exclude_patterns.iter().any(|pattern| {
17832 wildcard_match(pattern, relative)
17833 || wildcard_match(pattern, &format!("{relative}/"))
17834 || wildcard_match(pattern, &format!("{relative}/placeholder"))
17835 })
17836}
17837
17838fn should_include_preview_file(
17839 relative: &str,
17840 include_patterns: &[String],
17841 exclude_patterns: &[String],
17842) -> bool {
17843 if relative.is_empty() {
17844 return true;
17845 }
17846
17847 let included = include_patterns.is_empty()
17848 || include_patterns
17849 .iter()
17850 .any(|pattern| wildcard_match(pattern, relative));
17851 let excluded = exclude_patterns
17852 .iter()
17853 .any(|pattern| wildcard_match(pattern, relative));
17854
17855 included && !excluded
17856}
17857
17858fn wildcard_match(pattern: &str, candidate: &str) -> bool {
17859 let pattern = pattern.trim().replace('\\', "/");
17860 let candidate = candidate.trim().replace('\\', "/");
17861 let p = pattern.as_bytes();
17862 let c = candidate.as_bytes();
17863 let mut pi = 0usize;
17864 let mut ci = 0usize;
17865 let mut star: Option<usize> = None;
17866 let mut star_match = 0usize;
17867
17868 while ci < c.len() {
17869 if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
17870 pi += 1;
17871 ci += 1;
17872 } else if pi < p.len() && p[pi] == b'*' {
17873 while pi < p.len() && p[pi] == b'*' {
17874 pi += 1;
17875 }
17876 star = Some(pi);
17877 star_match = ci;
17878 } else if let Some(star_pi) = star {
17879 star_match += 1;
17880 ci = star_match;
17881 pi = star_pi;
17882 } else {
17883 return false;
17884 }
17885 }
17886
17887 while pi < p.len() && p[pi] == b'*' {
17888 pi += 1;
17889 }
17890
17891 pi == p.len()
17892}
17893
17894fn escape_html(value: &str) -> String {
17895 value
17896 .replace('&', "&")
17897 .replace('<', "<")
17898 .replace('>', ">")
17899 .replace('"', """)
17900 .replace('\'', "'")
17901}
17902
17903#[derive(Clone)]
17904struct SubmoduleRow {
17905 name: String,
17906 relative_path: String,
17907 files_analyzed: u64,
17908 code_lines: u64,
17909 comment_lines: u64,
17910 blank_lines: u64,
17911 total_physical_lines: u64,
17912 html_url: Option<String>,
17913}
17914
17915#[derive(Template)]
17916#[template(
17917 source = r##"
17918<!doctype html>
17919<html lang="en">
17920<head>
17921 <meta charset="utf-8">
17922 <title>OxideSLOC | tmp-sloc</title>
17923 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17924 <style nonce="{{ csp_nonce }}">
17925 :root {
17926 --bg: #efe9e2;
17927 --surface: #fcfaf7;
17928 --surface-2: #f7f0e8;
17929 --surface-3: #efe3d5;
17930 --line: #dfcfbf;
17931 --line-strong: #cfb29c;
17932 --text: #2f241c;
17933 --muted: #6f6257;
17934 --muted-2: #917f71;
17935 --nav: #b85d33;
17936 --nav-2: #7a371b;
17937 --accent: #2563eb;
17938 --accent-2: #1d4ed8;
17939 --oxide: #b85d33;
17940 --oxide-2: #8f4220;
17941 --success-bg: #eaf9ee;
17942 --success-text: #1c8746;
17943 --warn-bg: #fff2d8;
17944 --warn-text: #926000;
17945 --danger-bg: #fdeaea;
17946 --danger-text: #b33b3b;
17947 --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
17948 --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
17949 --radius: 14px;
17950 }
17951
17952 body.dark-theme {
17953 --bg: #1b1511;
17954 --surface: #261c17;
17955 --surface-2: #2d221d;
17956 --surface-3: #372922;
17957 --line: #524238;
17958 --line-strong: #6c5649;
17959 --text: #f5ece6;
17960 --muted: #c7b7aa;
17961 --muted-2: #aa9485;
17962 --nav: #b85d33;
17963 --nav-2: #7a371b;
17964 --accent: #6f9bff;
17965 --accent-2: #4a78ee;
17966 --oxide: #d37a4c;
17967 --oxide-2: #b35428;
17968 --success-bg: #163927;
17969 --success-text: #8fe2a8;
17970 --warn-bg: #3c2d11;
17971 --warn-text: #f3cb75;
17972 --danger-bg: #3d1f1f;
17973 --danger-text: #ff9f9f;
17974 --shadow: 0 14px 28px rgba(0,0,0,0.28);
17975 --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
17976 }
17977
17978 * { box-sizing: border-box; }
17979 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); }
17980 html { overflow-y: scroll; }
17981 body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
17982 .top-nav, .page, .loading { position: relative; z-index: 2; }
17983 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
17984 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
17985 .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); }
17986 .top-nav-inner { max-width: 1720px; margin: 0 auto; padding: 4px 24px; min-height: 56px; display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 18px; }
17987 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
17988 .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)); }
17989 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
17990 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
17991 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
17992 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
17993 .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; }
17994 .nav-project-pill.visible { display:inline-flex; }
17995 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
17996 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
17997 .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
17998 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
17999 @media (max-width: 1150px) { .nav-status { gap: 4px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 8px; font-size: 11px; min-height: 34px; } .brand-subtitle { display: none; } .server-online-pill { width: 34px; padding: 0; justify-content: center; font-size: 0; gap: 0; min-height: 34px; } }
18000 .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); white-space: nowrap; text-decoration:none; transition:background .15s ease,transform .15s ease; }
18001 a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
18002 .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; }
18003 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
18004 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
18005 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
18006 .theme-toggle .icon-sun { display:none; }
18007 body.dark-theme .theme-toggle .icon-sun { display:block; }
18008 body.dark-theme .theme-toggle .icon-moon { display:none; }
18009 .settings-modal{position:fixed;z-index:9999;background:var(--surface-2);border:1px solid var(--line-strong);border-radius:14px;box-shadow:0 12px 36px rgba(0,0,0,0.22);min-width:260px;max-width:320px;opacity:0;pointer-events:none;transform:translateY(-8px) scale(0.97);transition:opacity 0.18s ease,transform 0.18s ease;overflow:hidden;}
18010 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
18011 .settings-modal-header{display:flex;align-items:center;justify-content:space-between;padding:14px 16px 10px;border-bottom:1px solid var(--line);font-size:13px;font-weight:800;color:var(--text);}
18012 .settings-close{background:none;border:none;cursor:pointer;width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--muted);border-radius:6px;padding:0;}
18013 .settings-close:hover{color:var(--text);background:var(--surface-2);}
18014 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
18015 .settings-modal-body{padding:14px 16px 16px;}
18016 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
18017 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
18018 .scheme-swatch{display:flex;flex-direction:column;align-items:center;gap:5px;background:none;border:1.5px solid var(--line);border-radius:10px;cursor:pointer;padding:7px 4px 6px;transition:border-color 0.15s ease,transform 0.12s ease;}
18019 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
18020 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
18021 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
18022 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
18023 .tz-select{width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2);color:var(--text);font-size:12px;font-weight:600;cursor:pointer;outline:none;box-sizing:border-box;}
18024 .tz-select:focus{border-color:var(--oxide);}
18025 .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; }
18026 .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;}
18027 .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; width: 100%; display: flex; flex-direction: column; }
18028 @media (max-width: 1920px) { .top-nav-inner { max-width: 1500px; } .page { max-width: 1500px; } }
18029 .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
18030 .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
18031 .workbench-box { border: 1px solid var(--line-strong); border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); transition: transform .2s ease, box-shadow .2s ease; }
18032 .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
18033 body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
18034 .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
18035 .wb-stats-header { padding: 10px 24px 0; }
18036 .wb-stats-title { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
18037 .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
18038 .ws-stat { display:flex; flex-direction:column; justify-content:center; 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); transition: transform .2s ease, box-shadow .2s ease; }
18039 .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
18040 body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
18041 .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
18042 .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
18043 .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; }
18044 body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
18045 .ws-stat-analyzers { position: relative; }
18046 .ws-lang-tooltip { display:none; position:absolute; top:calc(100% + 6px); left:0; z-index:9999; 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; }
18047 .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
18048 .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
18049 .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
18050 .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
18051 .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; }
18052 body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
18053 .ws-divider { display: none; }
18054 .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%; }
18055 .ws-path-link:hover { color:var(--oxide); }
18056 body.dark-theme .ws-path-link { color:var(--oxide); }
18057 .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
18058 .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
18059 .ws-stat-clamp { max-width: 200px; overflow: hidden; }
18060 .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
18061 .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
18062 .ws-mini-box-sm .ws-mini-label { font-size:9px; }
18063 .ws-mini-box-sm .ws-mini-value { font-size:13px; }
18064 .ws-mini-box-lg { flex:2 1 0; }
18065 .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
18066 .ws-mini-box-br { flex:1.5 1 0; }
18067 .scope-legend-row { display:flex; flex-direction:row; align-items:center; justify-content:flex-start; flex-wrap:nowrap; gap:0; padding:5px 10px; border:1px solid var(--line); border-radius:8px; background:var(--surface-2); font-size:12px; width:100%; min-width:0; border-left:3px solid var(--line-strong); white-space:nowrap; }
18068 .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; flex-shrink:0; margin-right:10px; }
18069 .path-scope-grid { display:grid; grid-template-columns: calc(42% - 7px) auto auto 1px 1fr; gap:0 8px; align-items:center; }
18070 #path.drag-over { background: rgba(37,99,235,0.05) !important; border-color: var(--accent) !important; box-shadow: 0 0 0 3px rgba(37,99,235,0.15) !important; }
18071 .path-scope-grid > input[type=text] { width:100%; min-width:0; }
18072 .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; }
18073 .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
18074 .git-source-banner strong { font-weight:800; color:var(--text); }
18075 .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; }
18076 body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
18077 .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
18078 .git-source-banner a:hover { text-decoration:underline; }
18079 .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
18080 .path-scope-sep { background:var(--line); margin:4px 14px; }
18081 .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
18082 .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
18083 .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
18084 .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
18085 .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
18086 .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
18087 .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; transition: transform .2s ease, box-shadow .2s ease; }
18088 .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
18089 body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
18090 .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
18091 .wb-ftip { position:fixed; z-index:9000; background:var(--surface); border:1px solid var(--line-strong); border-radius:10px; box-shadow:0 8px 28px rgba(0,0,0,0.18); padding:10px 14px; font-size:12px; line-height:1.55; color:var(--text); max-width:300px; white-space:normal; pointer-events:none; display:none; text-align:left; }
18092 .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
18093 .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
18094 [data-wb-tip] { cursor:help; }
18095 .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
18096 .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
18097 .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; }
18098 .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
18099 .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
18100 body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
18101 .summary-card, .card, .step-nav, .explainer-card, .review-card, .workspace-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; }
18102 .summary-card:hover, .workspace-card:hover, .explainer-card:hover, .review-card:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); transform: translateY(-2px); }
18103 .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
18104 .side-info-card { padding: 18px; }
18105 .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
18106 .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
18107 .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
18108 .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
18109 .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); }
18110 .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
18111 .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
18112 .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
18113 .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; }
18114 .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:stretch; flex: 1; min-height: 0; }
18115 .side-stack { display:grid; gap: 16px; align-items:start; align-self: start; position: sticky; top: 73px; max-height: calc(100vh - 90px); overflow-y: auto; width: 244px; max-width: 244px; scrollbar-width: none; }
18116 .side-stack::-webkit-scrollbar { display: none; }
18117 .step-nav { padding: 20px 16px; }
18118 .step-nav h3 { margin: 6px 4px 14px; font-size: 16px; font-weight: 850; letter-spacing: -0.01em; }
18119 .step-button { width:100%; display:flex; align-items:center; gap:10px; border:none; background:transparent; border-radius: 12px; padding: 11px 8px; color: var(--text); cursor:pointer; text-align:left; font-size:13px; font-weight:700; white-space:nowrap; transition: background 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease; animation: stepEntrance 0.3s ease both; }
18120 .step-button:hover { background: var(--surface-2); }
18121 .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); }
18122 .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; }
18123 .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
18124 .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
18125 .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
18126 .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); }
18127 .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
18128 .step-nav-sum-row:last-child { border-bottom:none; }
18129 .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
18130 .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; }
18131 .step-steps-divider { height:1px; background:var(--line); margin: 12px 4px; }
18132 .quick-scan-divider { height:1px; background:var(--line); margin: 12px 4px; }
18133 .quick-scan-section { padding: 10px 4px 14px; }
18134 .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
18135 .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; }
18136 .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
18137 .quick-scan-btn:active { transform:translateY(0); }
18138 .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
18139 .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
18140 .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
18141 @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);} }
18142 @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
18143 .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
18144 .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
18145 .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
18146 .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
18147 .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
18148 .step-button.done .step-check { opacity:1; }
18149 .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
18150 .sidebar-kbd-hint { margin:14px 4px 0; font-size:10px; color:var(--muted-2); line-height:1.55; text-align:center; display:flex; align-items:center; justify-content:center; gap:4px; }
18151 .sidebar-kbd-key { display:inline-flex; align-items:center; justify-content:center; padding:1px 5px; border-radius:4px; background:var(--surface-3); border:1px solid var(--line); font-size:9px; font-weight:700; color:var(--muted); font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; line-height:1; }
18152 .sidebar-scroll-divider { height:1px; background:var(--line); margin: 12px 4px; }
18153 .sidebar-scroll-btn { display:flex; align-items:center; justify-content:center; gap:5px; width:100%; padding:7px 10px; border-radius:9px; border:1px solid var(--line); background:var(--surface-2); color:var(--muted); font-size:11px; font-weight:700; text-decoration:none; cursor:pointer; transition:background 0.15s ease,border-color 0.15s ease,color 0.15s ease; }
18154 .sidebar-scroll-btn:hover { background:var(--surface-3); border-color:var(--line-strong); color:var(--text); text-decoration:none; }
18155 .sidebar-scroll-btn svg { width:12px; height:12px; stroke:currentColor; fill:none; stroke-width:2.5; flex-shrink:0; }
18156 .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; }
18157 body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
18158 .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
18159 .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
18160 .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
18161 .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
18162 .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
18163 .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
18164 .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
18165 .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
18166 .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
18167 .card-body { padding: 22px; }
18168 .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
18169 .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
18170 @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
18171 .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
18172 .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
18173 .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
18174 .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
18175 .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
18176 .field { min-width:0; }
18177 label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
18178 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; }
18179 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); }
18180 input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
18181 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); }
18182 textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
18183 textarea.glob-textarea { font-size: 13px; padding: 10px 12px; }
18184 .glob-label-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:6px; min-height:28px; }
18185 .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
18186 .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; }
18187 .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
18188 .path-history-badge.new { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
18189 .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
18190 body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
18191 .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
18192 .input-group.compact { grid-template-columns: 1fr auto auto; }
18193 .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
18194 .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)); }
18195 .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
18196 .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
18197 .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
18198 .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
18199 .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; }
18200 .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
18201 .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; }
18202 .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); }
18203 .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
18204 .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
18205 #browse-path { min-height: 38px; font-size: 13px; padding: 0 18px; }
18206 #use-sample-path { min-height: 38px; font-size: 13px; padding: 0 13px; }
18207 .scope-legend-badges { display:flex; flex:1; align-items:center; justify-content:space-evenly; gap:6px; min-width:0; flex-wrap:nowrap; }
18208 .scope-legend-row .badge { flex:0 0 auto; font-size: 11px; min-height: 24px; padding: 0 10px; white-space: nowrap; }
18209 @media (max-height: 1200px) { .workbench-strip { margin-bottom: 12px; } .wb-stats-header { padding: 8px 20px 0; } .ws-left { padding: 10px 16px 12px; } .ws-history-group { padding: 12px 20px; } }
18210 button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
18211 button.secondary { background: var(--surface); }
18212 button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
18213 button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
18214 button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
18215 button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
18216 .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); }
18217 .section + .wizard-actions { border-top: none; padding-top: 0; }
18218 .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; align-items:center; }
18219 .default-path-overlay { position: fixed; inset: 0; z-index: 9000; background: rgba(0,0,0,0.52); display: flex; align-items: center; justify-content: center; padding: 24px; opacity: 0; pointer-events: none; transition: opacity .18s ease; }
18220 .default-path-overlay.open { opacity: 1; pointer-events: auto; }
18221 .default-path-modal { background: var(--surface); border: 1px solid var(--line); border-radius: 20px; max-width: 682px; width: 100%; box-shadow: 0 30px 80px rgba(0,0,0,0.34); padding: 33px 37px 29px; transform: translateY(10px); transition: transform .18s ease; }
18222 .default-path-overlay.open .default-path-modal { transform: translateY(0); }
18223 .default-path-modal h3 { margin: 0 0 15px; font-size: 22px; color: var(--text); display: flex; align-items: center; gap: 12px; }
18224 .default-path-modal h3 svg { width: 26px; height: 26px; flex-shrink: 0; color: var(--accent); }
18225 .default-path-modal p { margin: 0 0 11px; font-size: 12px; line-height: 1.6; color: var(--muted); }
18226 .default-path-modal p code { background: rgba(0,0,0,0.06); padding: 1px 6px; border-radius: 5px; font-size: 11.5px; color: var(--text); }
18227 body.dark-theme .default-path-modal p code { background: rgba(255,255,255,0.10); }
18228 .default-path-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 24px; }
18229 .default-path-actions button { font-size: 10.5px; padding: 6px 13px; border-radius: 8px; }
18230 .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
18231 .field-help-grid.coupled-help { margin-top: 12px; }
18232 .field-help-grid.preset-grid { align-items: start; }
18233 .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
18234 .preset-inline-row .field { margin: 0; }
18235 .preset-inline-row .explainer-card { margin: 0; }
18236 .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
18237 .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
18238 .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
18239 .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
18240 .preset-kv-row > :last-child { flex:1; min-width:0; }
18241 .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
18242 .output-field-row .field { margin: 0; }
18243 .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; }
18244 .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
18245 .step3-subtitle { margin-bottom: 10px; max-width: none; }
18246 .counting-intro { margin-bottom: 8px; max-width: none; }
18247 .ieee-note { margin-bottom: 22px; padding: 14px; border-radius: 12px; border: 1px solid var(--line); border-left: 4px solid var(--oxide); background: linear-gradient(180deg, rgba(184,93,51,0.08), transparent), var(--surface-2); font-size: 15px; line-height: 1.65; }
18248 .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
18249 .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
18250 .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; }
18251 .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; }
18252 .section-spacer-top { margin-top: 28px; }
18253 .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
18254 .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
18255 .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
18256 .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); }
18257 .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
18258 .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; }
18259 .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; }
18260 .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
18261 .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
18262 .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
18263 .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
18264 .lbl-opt { font-weight:400; font-size:12px; color:var(--muted); margin-left:4px; }
18265 .include-scope-badge { display:flex; align-items:center; gap:7px; padding:7px 12px; border-radius:8px; font-size:12px; font-weight:700; margin-bottom:7px; transition:background .2s,color .2s,border-color .2s; }
18266 .include-scope-badge.scope-all { background:rgba(42,104,70,0.1); border:1px solid rgba(42,104,70,0.25); color:#2a6846; }
18267 .include-scope-badge.scope-narrow { background:rgba(184,93,51,0.08); border:1px solid rgba(184,93,51,0.22); color:var(--nav,#b85d33); }
18268 body.dark-theme .include-scope-badge.scope-all { background:rgba(90,186,138,0.12); border-color:rgba(90,186,138,0.3); color:#5aba8a; }
18269 body.dark-theme .include-scope-badge.scope-narrow { background:rgba(210,130,70,0.12); border-color:rgba(210,130,70,0.3); color:#e0a060; }
18270 .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
18271 .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
18272 .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
18273 .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
18274 .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
18275 .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
18276 .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
18277 .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
18278 .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); }
18279 .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
18280 .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
18281 .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; }
18282 .docstring-example-inset .field-help-title { margin-bottom: 6px; }
18283 .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; width:100%; box-sizing:border-box; }
18284 .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; }
18285 .always-tracked-tip-body { flex:1; min-width:0; }
18286 .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
18287 .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
18288 .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
18289 .always-tracked-metrics-row { display:grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap:6px 18px; margin:8px 0 0; }
18290 .always-tracked-metrics-row > div { font-size:13px; color:var(--muted); line-height:1.5; }
18291 .always-tracked-metrics-row strong { display:block; font-size:13px; color:var(--text); margin-bottom:2px; white-space:nowrap; }
18292 @media (max-width:900px) { .always-tracked-metrics-row { grid-template-columns: repeat(2,minmax(0,1fr)); } }
18293 .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
18294 .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
18295 .advanced-rule-description strong { color: var(--text); }
18296 .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
18297 .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
18298 .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
18299 .review-link:hover { text-decoration: underline; }
18300 .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
18301 .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
18302 .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
18303 .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
18304 .review-card h4 { margin: 0 0 8px; font-size: 17px; }
18305 .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
18306 .review-card ul { padding-left: 18px; margin: 0; }
18307 .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
18308 .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
18309 .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
18310 .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
18311 .review-card { min-height: 0; }
18312 .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
18313 .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
18314 .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
18315 .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
18316 .lang-overflow-chip { position:relative; cursor:default; }
18317 .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; }
18318 .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
18319 .git-inline-row { align-items:start; }
18320 .mixed-line-card { display:flex; flex-direction:column; }
18321 .preset-inline-row .toggle-card { justify-content: center; }
18322 .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
18323 .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
18324 .explorer-toolbar.compact { padding: 0; border-bottom: none; }
18325 .explorer-title { font-size: 18px; font-weight: 850; }
18326 .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
18327 .explorer-subtitle.wide { max-width: none; }
18328 .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
18329 .better-spacing { align-items:flex-start; justify-content:flex-end; }
18330 .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; }
18331 .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
18332 .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
18333 .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
18334 .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
18335 body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
18336 .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
18337 .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; }
18338 .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
18339 .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
18340 .scope-stat-button.supported { background: var(--success-bg); }
18341 .scope-stat-button.skipped { background: var(--warn-bg); }
18342 .scope-stat-button.unsupported { background: var(--danger-bg); }
18343 .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
18344 .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
18345 .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
18346 [data-tooltip] { position: relative; }
18347 [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); }
18348 [data-tooltip]:hover::after { display: block; }
18349 .scope-stat-button[data-tooltip] { cursor: pointer; }
18350 .badge[data-tooltip] { cursor: help; }
18351 .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
18352 .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
18353 .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
18354 .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; }
18355 .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; }
18356 code { display:inline-block; margin-top:0; padding:2px 7px; }
18357 .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
18358 .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
18359 .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
18360 .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
18361 .language-pill.muted-pill { color: var(--muted); }
18362 button.language-pill { appearance:none; cursor:pointer; }
18363 .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); }
18364 .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
18365 .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; }
18366 .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
18367 .file-explorer-search-row { margin-left: auto; }
18368 .explorer-filter-select { min-width: 170px; width: 170px; }
18369 .explorer-search { min-width: 300px; width: 300px; }
18370 .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); }
18371 .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; }
18372 .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
18373 .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
18374 .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
18375 .file-explorer-tree { max-height: 640px; overflow:auto; }
18376 .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); }
18377 .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
18378 body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
18379 .tree-row.hidden-by-filter { display:none !important; }
18380 .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
18381 .tree-name-cell { display:flex; align-items:center; gap: 10px; padding-left: calc(var(--depth) * 22px + 8px); position: relative; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; min-width:0; }
18382 .tree-toggle { width: 22px; height: 22px; display:inline-flex; align-items:center; justify-content:center; border:none; background: var(--surface-2); color: var(--muted-2); cursor:pointer; font-size: 14px; line-height: 1; flex:0 0 22px; border-radius: 6px; border: 1px solid var(--line); font-weight: 900; }
18383 .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
18384 .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
18385 .tree-node { display:inline-flex; align-items:center; min-width:0; }
18386 .tree-node-dir { color: var(--text); font-weight: 800; }
18387 .tree-node-supported { color: var(--success-text); }
18388 .tree-node-skipped { color: var(--warn-text); }
18389 .tree-node-unsupported { color: var(--danger-text); }
18390 .tree-node-more { color: var(--muted-2); font-style: italic; }
18391 .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
18392 .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
18393 .tree-status-cell { display:flex; justify-content:flex-start; }
18394 .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
18395 .preview-warning { color: var(--warn-text); background: var(--warn-bg); border:1px solid var(--warn-text); border-radius: 12px; padding: 14px 16px; margin-bottom: 12px; font-size: 13px; line-height: 1.5; }
18396 .preview-warning strong { display:block; font-size: 14px; margin-bottom: 4px; }
18397 .preview-warning p { margin: 0 0 10px; }
18398 .repo-pick-row { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-bottom: 10px; }
18399 .repo-pick { font-family: inherit; font-size: 12px; font-weight: 600; color: var(--warn-text); background: transparent; border:1px solid var(--warn-text); border-radius: 999px; padding: 4px 12px; cursor: pointer; transition: background .15s ease, color .15s ease; }
18400 .repo-pick:hover { background: var(--warn-text); color: var(--warn-bg); }
18401 .repo-pick-more { font-size: 12px; font-style: italic; opacity: 0.85; }
18402 .multi-repo-ack-label { display:flex; align-items:center; gap:8px; font-size: 12px; font-weight: 600; cursor: pointer; }
18403 .multi-repo-ack { width:15px; height:15px; accent-color: var(--warn-text); cursor: pointer; }
18404 .preview-hint { color: var(--muted); background: var(--surface-2); border:1px solid var(--line); padding: 18px 20px; border-radius: 12px; font-size:14px; text-align:center; }
18405 .preview-loading { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
18406 .preview-spinner { width:18px; height:18px; border:2.5px solid var(--line); border-top-color:var(--oxide); border-radius:50%; animation:prevSpin 0.75s linear infinite; flex:0 0 18px; }
18407 @keyframes prevSpin { to { transform:rotate(360deg); } }
18408 .preview-gate-status { display:flex; align-items:center; gap:9px; font-size:13px; font-weight:600; color:var(--muted); margin-right:18px; }
18409 .preview-gate-spinner { width:15px; height:15px; border:2.5px solid var(--line); border-top-color:var(--oxide); border-radius:50%; animation:prevSpin 0.75s linear infinite; flex:0 0 15px; }
18410 .preview-gate-info { display:inline-flex; align-items:center; justify-content:center; width:18px; height:18px; padding:0; border:none; background:transparent; color:var(--oxide); cursor:pointer; border-radius:50%; flex:0 0 18px; transition:transform .15s ease, color .15s ease; }
18411 .preview-gate-info:hover { transform:scale(1.15); color:var(--nav); }
18412 .preview-gate-info svg { width:16px; height:16px; }
18413 .preview-panel-flash { animation:previewPanelFlash 1.4s ease; border-radius:12px; }
18414 @keyframes previewPanelFlash { 0%,100% { box-shadow:0 0 0 0 rgba(196,93,42,0); } 25% { box-shadow:0 0 0 4px rgba(196,93,42,0.45); } }
18415 button.next-step.is-blocked { opacity:0.55; cursor:not-allowed; pointer-events:none; box-shadow:none; transform:none; }
18416 .preview-loading-text { flex:1; min-width:0; }
18417 .preview-loading-msg { font-size:13px; color:var(--text); font-weight:600; }
18418 .preview-loading-elapsed { font-size:11px; color:var(--muted); margin-top:2px; }
18419 .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
18420 .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
18421 .cov-scan-idle { display:none; }
18422 .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
18423 .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
18424 .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
18425 .cov-scan-title { font-weight:600; font-size:12.5px; }
18426 .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
18427 .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
18428 .cov-scan-use { appearance:none; padding:3px 12px; border-radius:999px; border:1px solid currentColor; background:transparent; font-size:11.5px; font-weight:700; cursor:pointer; white-space:nowrap; }
18429 .cov-scan-use:hover { opacity:.75; }
18430 .cov-scan-cmd { font-family:monospace; font-size:11px; background:rgba(0,0,0,0.07); padding:2px 7px; border-radius:4px; word-break:break-all; }
18431 .cov-scan-tool { display:inline-block; font-size:10.5px; font-weight:700; padding:1px 7px; border-radius:999px; margin-left:4px; vertical-align:middle; }
18432 @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
18433 .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
18434 .cov-scan-scanning .cov-scan-title { color:var(--muted); }
18435 .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
18436 .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
18437 .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
18438 .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
18439 .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
18440 body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
18441 body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
18442 body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
18443 body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
18444 .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
18445 body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
18446 .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
18447 .cov-scan-hint .cov-scan-title { color:#7a5e00; }
18448 .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
18449 .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
18450 body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
18451 body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
18452 body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
18453 body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
18454 .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
18455 .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
18456 .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); }
18457 .loading.active { display:flex; }
18458 /* Lock page scroll while the analysis modal is open so the removed scrollbar
18459 gutter doesn't pull the centered card slightly left of true center. */
18460 body.modal-open { overflow: hidden; }
18461 .loading-card { position:relative; overflow:hidden; width: min(840px, calc(100vw - 40px)); border-radius: 20px; border: 1px solid var(--line); background: var(--surface); box-shadow: 0 24px 56px rgba(0,0,0,0.26); padding: 42px 48px; }
18462 /* Pulsating gradient sheen behind the modal content — replaces the old "Analysis running" pill */
18463 .loading-card::before { content:''; position:absolute; inset:0; z-index:0; pointer-events:none; border-radius:inherit; opacity:0; background: radial-gradient(130% 95% at 18% 0%, rgba(211,122,76,0.22), transparent 58%), radial-gradient(120% 90% at 100% 100%, rgba(37,99,235,0.16), transparent 55%), radial-gradient(140% 120% at 50% 120%, rgba(184,93,51,0.14), transparent 60%); transition: opacity .4s ease; }
18464 .loading-card.lc-pulsing::before { animation: lcCardPulse 3.6s ease-in-out infinite; }
18465 .loading-card > * { position:relative; z-index:1; }
18466 @keyframes lcCardPulse { 0%,100%{opacity:0.45;} 50%{opacity:1;} }
18467 body.dark-theme .loading-card::before { background: radial-gradient(130% 95% at 18% 0%, rgba(211,122,76,0.26), transparent 58%), radial-gradient(120% 90% at 100% 100%, rgba(111,155,255,0.18), transparent 55%), radial-gradient(140% 120% at 50% 120%, rgba(184,93,51,0.18), transparent 60%); }
18468 .progress-bar { width:100%; height:9px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
18469 .progress-bar span { display:block; width:35%; height:100%; border-radius:999px; background: linear-gradient(90deg, transparent, var(--accent-2) 22%, var(--oxide,#d37a4c) 78%, transparent); will-change: transform; animation: pulseBar 1.5s linear infinite; }
18470 @keyframes pulseBar { 0% { transform: translateX(-130%); } 100% { transform: translateX(330%); } }
18471 .lc-title { font-size:1.44rem;font-weight:800;margin:0 0 6px; }
18472 .lc-sub { color:var(--muted);font-size:0.9rem;margin:0 0 18px; }
18473 .lc-path { background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:10px 16px;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px;color:var(--muted);word-break:break-all;margin-bottom:18px;display:flex;align-items:center;gap:10px; }
18474 .lc-metrics { display:flex;gap:10px;margin-bottom:16px; }
18475 .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:10px 14px;flex:1 1 0;min-width:0; }
18476 .lc-metric-label { font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
18477 .lc-metric-value { font-size:1rem;font-weight:800;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
18478 .lc-stage-desc { font-size:12px;color:var(--muted);background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:9px 14px;margin-bottom:18px;line-height:1.5;transition:opacity .3s; }
18479 .lc-steps { display:flex;align-items:center;gap:0;margin-bottom:18px; }
18480 .lc-step { display:flex;align-items:center;gap:6px;padding:5px 12px;border-radius:999px;color:var(--muted);border:1.5px solid transparent;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;transition:all .25s; }
18481 .lc-step.active { color:var(--oxide,#d37a4c);background:rgba(211,122,76,0.1);border-color:rgba(211,122,76,0.32); }
18482 .lc-step.done { color:var(--muted);opacity:0.55; }
18483 .lc-step-num { width:18px;height:18px;border-radius:50%;background:rgba(150,140,130,0.2);color:var(--muted);display:inline-flex;align-items:center;justify-content:center;font-size:10px;font-weight:900;flex:0 0 auto; }
18484 .lc-step.active .lc-step-num { background:var(--oxide,#d37a4c);color:#fff; }
18485 .lc-step.done .lc-step-num { background:rgba(80,180,100,0.22);color:#2d8a45; }
18486 .lc-step-arrow { color:var(--line-strong,#ccc);font-size:16px;padding:0 8px;flex:0 0 auto;line-height:1; }
18487 .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; }
18488 .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; }
18489 .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
18490 .lc-err p { margin:0;font-size:12px;color:var(--muted); }
18491 .lc-cancelled { background:rgba(100,100,100,0.08);border:1px solid rgba(100,100,100,0.22);border-radius:8px;padding:12px 16px;margin-top:14px; }
18492 .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
18493 .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
18494 .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; }
18495 .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
18496 .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
18497 .quick-excl-chip { display:inline-flex;align-items:center;padding:3px 10px;border-radius:999px;background:rgba(37,99,235,0.07);border:1px solid rgba(37,99,235,0.2);color:var(--accent-2);font-size:11px;font-weight:700;cursor:pointer;transition:background .12s,border-color .12s; }
18498 .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
18499 .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
18500 .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
18501 .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
18502 body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
18503 body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
18504 .lc-cancel-btn { display:inline-flex;align-items:center;gap:6px;margin-top:14px;padding:8px 18px;border-radius:999px;background:transparent;color:var(--muted);border:1.5px solid rgba(150,150,150,0.35);font-size:12px;font-weight:700;cursor:pointer;transition:color .15s,border-color .15s; }
18505 .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
18506 body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
18507 .hidden { display:none !important; }
18508 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
18509 .site-footer a{color:var(--muted);}
18510 @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
18511 @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; } }
18512 .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;}
18513 @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));}}
18514 .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;white-space:nowrap;text-decoration:none;}.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;}
18515 .submodule-preview-strip { display:flex; align-items:center; gap:14px; padding:12px 16px; border:1px solid rgba(37,99,235,0.2); border-radius:12px; background:linear-gradient(180deg,rgba(37,99,235,0.05),transparent),var(--surface-2); flex-wrap:wrap; }
18516 .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
18517 .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
18518 .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
18519 .submodule-preview-chip { appearance:none; display:inline-flex; align-items:center; padding:3px 11px; border-radius:999px; font-size:12px; font-weight:700; background:rgba(37,99,235,0.09); border:1px solid rgba(37,99,235,0.22); color:var(--accent-2); cursor:pointer; position:relative; transition:background .15s ease, box-shadow .15s ease; }
18520 .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
18521 .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
18522 .submodule-chip-tooltip { position:absolute; bottom:calc(100% + 8px); left:50%; transform:translateX(-50%) translateY(7px); background:var(--text); color:var(--bg); padding:5px 10px; border-radius:7px; font-size:11px; font-weight:600; white-space:nowrap; pointer-events:none; opacity:0; transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1); z-index:300; }
18523 .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
18524 .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
18525 .submodule-base-repo-btn { appearance:none; display:inline-flex; align-items:center; gap:5px; padding:3px 11px; border-radius:999px; font-size:12px; font-weight:700; background:rgba(77,44,20,0.1); border:1px solid rgba(77,44,20,0.25); color:var(--text); cursor:pointer; transition:background .15s ease; }
18526 .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
18527 .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
18528 .info-icon-btn { appearance:none; display:inline-flex; align-items:center; gap:5px; background:none; border:none; cursor:pointer; color:var(--muted); font-size:12px; font-weight:600; padding:2px 0; line-height:1.4; }
18529 .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
18530 .info-icon-btn:hover { color:var(--text); }
18531 body.dark-theme .submodule-preview-strip { border-color:rgba(111,155,255,0.22); background:linear-gradient(180deg,rgba(37,99,235,0.09),transparent),var(--surface-2); }
18532 body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
18533 body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
18534 .toast-success{display:flex;align-items:center;gap:10px;background:#e8f5ed;border:1px solid #a3d9b1;border-radius:10px;padding:10px 16px;font-size:13px;color:#1a5c35;font-weight:600;}
18535 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
18536 .toast-error{display:flex;align-items:center;gap:10px;background:#fde8e8;border:1px solid #f5a3a3;border-radius:10px;padding:10px 16px;font-size:13px;color:#7a1a1a;font-weight:600;}
18537 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
18538 #offline-file-banner{display:none;position:sticky;top:0;z-index:9999;background:#fff8e1;border-bottom:2px solid #f0b429;padding:10px 20px;font-size:13px;font-weight:600;color:#7a5000;align-items:center;gap:12px;box-shadow:0 2px 10px rgba(0,0,0,0.12);}
18539 #offline-file-banner.show{display:flex;}
18540 #offline-file-banner svg{flex-shrink:0;width:20px;height:20px;stroke:#f0b429;fill:none;stroke-width:2;}
18541 #offline-file-banner .ofb-text{flex:1;}
18542 #offline-file-banner .ofb-text a{color:#b35c00;font-weight:700;text-decoration:underline;}
18543 #offline-file-banner .ofb-code{background:rgba(0,0,0,0.08);padding:1px 5px;border-radius:4px;font-size:12px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;}
18544 #offline-file-banner .ofb-dismiss{margin-left:auto;background:none;border:1px solid #d4950a;border-radius:6px;color:#7a5000;font-size:12px;font-weight:700;padding:3px 10px;cursor:pointer;white-space:nowrap;}
18545 #offline-file-banner .ofb-dismiss:hover{background:#feefc3;}
18546 body.dark-theme #offline-file-banner{background:#2d2200;border-bottom-color:#c98a00;color:#e8c96a;}
18547 body.dark-theme #offline-file-banner svg{stroke:#c98a00;}
18548 body.dark-theme #offline-file-banner .ofb-text a{color:#f0c040;}
18549 body.dark-theme #offline-file-banner .ofb-code{background:rgba(255,255,255,0.08);}
18550 body.dark-theme #offline-file-banner .ofb-dismiss{border-color:#9a6a00;color:#e8c96a;}
18551 body.dark-theme #offline-file-banner .ofb-dismiss:hover{background:rgba(240,180,0,0.12);}
18552 </style>
18553</head>
18554<body id="page-top">
18555 <div id="offline-file-banner" role="alert">
18556 <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
18557 <span class="ofb-text">
18558 Charts, images, and navigation require the oxide-sloc server.
18559 Start it with <span class="ofb-code">cargo run -p oxide-sloc</span> or <span class="ofb-code">bash run.sh</span>,
18560 then open this run at <a href="http://127.0.0.1:4317" target="_blank" rel="noopener">http://127.0.0.1:4317</a>.
18561 The metric tables below are fully readable without the server.
18562 </span>
18563 <button class="ofb-dismiss" id="ofb-dismiss-btn" type="button">Dismiss</button>
18564 </div>
18565 <script nonce="{{ csp_nonce }}">(function(){if(location.protocol==='file:'){var b=document.getElementById('offline-file-banner');if(b)b.classList.add('show');var d=document.getElementById('ofb-dismiss-btn');if(d)d.addEventListener('click',function(){b.classList.remove('show');});}})();</script>
18566 <div class="background-watermarks" aria-hidden="true">
18567 <img src="/images/logo/logo-text.png" alt="" />
18568 <img src="/images/logo/logo-text.png" alt="" />
18569 <img src="/images/logo/logo-text.png" alt="" />
18570 <img src="/images/logo/logo-text.png" alt="" />
18571 <img src="/images/logo/logo-text.png" alt="" />
18572 <img src="/images/logo/logo-text.png" alt="" />
18573 <img src="/images/logo/logo-text.png" alt="" />
18574 <img src="/images/logo/logo-text.png" alt="" />
18575 <img src="/images/logo/logo-text.png" alt="" />
18576 <img src="/images/logo/logo-text.png" alt="" />
18577 <img src="/images/logo/logo-text.png" alt="" />
18578 <img src="/images/logo/logo-text.png" alt="" />
18579 <img src="/images/logo/logo-text.png" alt="" />
18580 <img src="/images/logo/logo-text.png" alt="" />
18581 </div>
18582 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18583 <div class="top-nav">
18584 <div class="top-nav-inner">
18585 <a class="brand" href="/">
18586 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
18587 <div class="brand-copy">
18588 <div class="brand-title">OxideSLOC</div>
18589 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
18590 </div>
18591 </a>
18592 <div class="nav-project-slot">
18593 <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
18594 <span class="nav-project-label">Project</span>
18595 <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
18596 </div>
18597 </div>
18598 <div class="nav-status">
18599 <a class="nav-pill" href="/">Home</a>
18600 <div class="nav-dropdown">
18601 <a href="/view-reports" class="nav-dropdown-btn">View Reports <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></a>
18602 <div class="nav-dropdown-menu">
18603 <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
18604 </div>
18605 </div>
18606 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
18607 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18608 <div class="nav-dropdown">
18609 <a href="/git-browser" class="nav-dropdown-btn">Git Browser <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></a>
18610 <div class="nav-dropdown-menu">
18611 <a href="/integrations"><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>Integrations</a>
18612 </div>
18613 </div>
18614 <div class="server-status-wrap" id="server-status-wrap">
18615 <div class="nav-pill server-online-pill" id="server-status-pill">
18616 <span class="status-dot" id="status-dot"></span>
18617 <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
18618 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
18619 </div>
18620 <div class="server-status-tip">
18621 {% if server_mode %}
18622 OxideSLOC is running in server mode — accessible on your LAN.
18623 {% else %}
18624 OxideSLOC is running locally — only accessible from this machine.
18625 {% endif %}
18626 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
18627 </div>
18628 </div>
18629 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
18630 <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
18631 </button>
18632 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
18633 <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>
18634 <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>
18635 </button>
18636 </div>
18637 </div>
18638 </div>
18639
18640 <div class="loading" id="loading">
18641 <div class="loading-card" id="loading-card">
18642 <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
18643 <p class="lc-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
18644 <div class="lc-path" id="lc-path"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" style="flex:0 0 auto;opacity:0.45"><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><span id="lc-path-text"></span></div>
18645 <div class="lc-steps" id="lc-steps">
18646 <div class="lc-step active" id="lc-step-1"><span class="lc-step-num">1</span>Discover</div>
18647 <div class="lc-step-arrow">›</div>
18648 <div class="lc-step" id="lc-step-2"><span class="lc-step-num">2</span>Analyze</div>
18649 <div class="lc-step-arrow">›</div>
18650 <div class="lc-step" id="lc-step-3"><span class="lc-step-num">3</span>Report</div>
18651 <div class="lc-step-arrow">›</div>
18652 <div class="lc-step" id="lc-step-4"><span class="lc-step-num">4</span>Done</div>
18653 </div>
18654 <div class="lc-stage-desc" id="lc-stage-desc">Initializing language analyzers and loading configuration…</div>
18655 <div class="lc-metrics" id="lc-metrics">
18656 <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
18657 <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
18658 <div class="lc-metric hidden" id="lc-files-card"><div class="lc-metric-label">Files</div><div class="lc-metric-value" id="lc-files">0</div></div>
18659 <div class="lc-metric hidden" id="lc-speed-card"><div class="lc-metric-label">Files/sec</div><div class="lc-metric-value" id="lc-speed">—</div></div>
18660 </div>
18661 <div class="progress-bar" id="lc-progress-bar"><span></span></div>
18662 <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>
18663 <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>
18664 <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
18665 <div class="lc-actions hidden" id="lc-actions">
18666 <button class="primary" id="lc-dismiss" type="button">Try Again</button>
18667 <a href="/view-reports" class="lc-outline-btn">View Reports</a>
18668 </div>
18669 <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
18670 <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
18671 Cancel scan
18672 </button>
18673 </div>
18674 </div>
18675
18676 <div class="page">
18677 <div class="workbench-strip">
18678 <div class="workbench-box wb-stats">
18679 <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
18680 <span class="wb-stats-title">Analysis session</span>
18681 </div>
18682 <div class="ws-left">
18683 <div class="ws-stat ws-stat-analyzers">
18684 <span class="ws-label">Analyzers</span>
18685 <span class="ws-value">
18686 <span class="ws-badge">60 languages</span>
18687 </span>
18688 <div class="ws-lang-tooltip">
18689 <div class="ws-lang-tooltip-hdr">60 supported languages</div>
18690 <div class="ws-lang-tooltip-desc">Language detection engines loaded for this session. Each engine uses a lexical state machine to count code, comment, and blank lines.</div>
18691 <div class="ws-lang-grid">
18692 <span class="ws-lang-item">Assembly</span>
18693 <span class="ws-lang-item">C</span>
18694 <span class="ws-lang-item">C++</span>
18695 <span class="ws-lang-item">C#</span>
18696 <span class="ws-lang-item">Clojure</span>
18697 <span class="ws-lang-item">CSS</span>
18698 <span class="ws-lang-item">Dart</span>
18699 <span class="ws-lang-item">Dockerfile</span>
18700 <span class="ws-lang-item">Elixir</span>
18701 <span class="ws-lang-item">Erlang</span>
18702 <span class="ws-lang-item">F#</span>
18703 <span class="ws-lang-item">Go</span>
18704 <span class="ws-lang-item">Groovy</span>
18705 <span class="ws-lang-item">Haskell</span>
18706 <span class="ws-lang-item">HTML</span>
18707 <span class="ws-lang-item">Java</span>
18708 <span class="ws-lang-item">JavaScript</span>
18709 <span class="ws-lang-item">Julia</span>
18710 <span class="ws-lang-item">Kotlin</span>
18711 <span class="ws-lang-item">Lua</span>
18712 <span class="ws-lang-item">Makefile</span>
18713 <span class="ws-lang-item">Nim</span>
18714 <span class="ws-lang-item">Obj-C</span>
18715 <span class="ws-lang-item">OCaml</span>
18716 <span class="ws-lang-item">Perl</span>
18717 <span class="ws-lang-item">PHP</span>
18718 <span class="ws-lang-item">PowerShell</span>
18719 <span class="ws-lang-item">Python</span>
18720 <span class="ws-lang-item">R</span>
18721 <span class="ws-lang-item">Ruby</span>
18722 <span class="ws-lang-item">Rust</span>
18723 <span class="ws-lang-item">Scala</span>
18724 <span class="ws-lang-item">SCSS</span>
18725 <span class="ws-lang-item">Shell</span>
18726 <span class="ws-lang-item">SQL</span>
18727 <span class="ws-lang-item">Svelte</span>
18728 <span class="ws-lang-item">Swift</span>
18729 <span class="ws-lang-item">TypeScript</span>
18730 <span class="ws-lang-item">Vue</span>
18731 <span class="ws-lang-item">XML</span>
18732 <span class="ws-lang-item">Zig</span>
18733 <span class="ws-lang-item">Solidity</span>
18734 <span class="ws-lang-item">Protobuf</span>
18735 <span class="ws-lang-item">HCL</span>
18736 <span class="ws-lang-item">GraphQL</span>
18737 <span class="ws-lang-item">Ada</span>
18738 <span class="ws-lang-item">VHDL</span>
18739 <span class="ws-lang-item">Verilog</span>
18740 <span class="ws-lang-item">Tcl</span>
18741 <span class="ws-lang-item">Pascal</span>
18742 <span class="ws-lang-item">Visual Basic</span>
18743 <span class="ws-lang-item">Lisp</span>
18744 <span class="ws-lang-item">Fortran</span>
18745 <span class="ws-lang-item">Nix</span>
18746 <span class="ws-lang-item">Crystal</span>
18747 <span class="ws-lang-item">D</span>
18748 <span class="ws-lang-item">GLSL</span>
18749 <span class="ws-lang-item">CMake</span>
18750 <span class="ws-lang-item">Elm</span>
18751 <span class="ws-lang-item">Awk</span>
18752 </div>
18753 </div>
18754 </div>
18755 <div class="ws-divider"></div>
18756 <div class="ws-stat ws-stat-clamp" data-wb-tip="Directory path of the project currently selected or most recently analyzed."><span class="ws-label">Active project</span><span class="ws-value" id="live-report-title">—</span></div>
18757 <div class="ws-divider"></div>
18758 <div class="ws-stat ws-stat-output" data-wb-tip="Folder where scan artifacts — JSON, HTML, and PDF reports — are written after each completed scan.">
18759 <span class="ws-label">Output</span>
18760 <span class="ws-value">
18761 <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
18762 <span id="ws-output-root">project/sloc</span>
18763 </button>
18764 </span>
18765 </div>
18766 </div>
18767 </div>
18768 <div class="workbench-box ws-history-group" data-wb-tip="Scan statistics aggregated across all runs completed for this project in the current server session.">
18769 <div class="ws-history-label">Scan history</div>
18770 <div class="ws-history-inner">
18771 <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
18772 <div class="ws-mini-label">Scans</div>
18773 <div class="ws-mini-value" id="ws-scan-count">—</div>
18774 </div>
18775 <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
18776 <div class="ws-mini-label">Last Scan</div>
18777 <div class="ws-mini-value" id="ws-last-scan">—</div>
18778 </div>
18779 <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
18780 <div class="ws-mini-label">Branch</div>
18781 <div class="ws-mini-value" id="ws-branch">—</div>
18782 </div>
18783 </div>
18784 </div>
18785 </div>
18786
18787 <div class="layout">
18788 <aside class="side-stack">
18789 <section class="step-nav">
18790 <h3>Guided scan setup</h3>
18791 <a href="#page-top" class="sidebar-scroll-btn" aria-label="Scroll to top of page">
18792 <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="18 15 12 9 6 15"></polyline></svg>
18793 Top of page
18794 </a>
18795 <button type="button" class="step-button active" style="margin-top:10px;" data-step-target="1"><span class="step-num">1</span><span>Select project</span><svg class="step-check" viewBox="0 0 24 24" stroke-width="2.5" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg></button>
18796 <button type="button" class="step-button" data-step-target="2"><span class="step-num">2</span><span>Counting rules</span><svg class="step-check" viewBox="0 0 24 24" stroke-width="2.5" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg></button>
18797 <button type="button" class="step-button" data-step-target="3"><span class="step-num">3</span><span>Outputs and reports</span><svg class="step-check" viewBox="0 0 24 24" stroke-width="2.5" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg></button>
18798 <button type="button" class="step-button" data-step-target="4"><span class="step-num">4</span><span>Review and run</span><svg class="step-check" viewBox="0 0 24 24" stroke-width="2.5" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg></button>
18799
18800 <div class="step-steps-divider"></div>
18801
18802 <div class="step-nav-info" id="step-nav-info">
18803 <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
18804 <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>
18805 </div>
18806
18807 <div class="step-nav-summary" id="sidebar-summary" style="display:none">
18808 <div class="step-nav-sum-row"><span class="step-nav-sum-key">Path</span><span class="step-nav-sum-val" id="sum-path">—</span></div>
18809 <div class="step-nav-sum-row"><span class="step-nav-sum-key">Preset</span><span class="step-nav-sum-val" id="sum-preset">—</span></div>
18810 <div class="step-nav-sum-row"><span class="step-nav-sum-key">Output</span><span class="step-nav-sum-val" id="sum-output">—</span></div>
18811 </div>
18812
18813 <div class="quick-scan-divider"></div>
18814 <div class="quick-scan-section">
18815 <div class="quick-scan-label">No customization needed?</div>
18816 <button type="button" id="quick-scan-btn" class="quick-scan-btn">
18817 <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>
18818 Quick Scan
18819 </button>
18820 <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2–4.</div>
18821 </div>
18822
18823 <div class="sidebar-kbd-hint"><span class="sidebar-kbd-key">←</span><span>Back</span><span style="margin:0 6px;">·</span><span class="sidebar-kbd-key">→</span><span>Next</span></div>
18824 <div class="sidebar-scroll-divider"></div>
18825 <a href="#page-bottom" class="sidebar-scroll-btn" aria-label="Skip to bottom of page">
18826 <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>
18827 Skip to bottom
18828 </a>
18829 </section>
18830
18831 </aside>
18832
18833 <section class="card">
18834 <div class="card-header">
18835 <div class="card-title-row">
18836 <div>
18837 <h1 class="card-title">Guided scan configuration</h1>
18838 <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
18839 </div>
18840 <div class="wizard-progress" aria-label="Scan setup progress">
18841 <div class="wizard-progress-top">
18842 <span class="wizard-progress-label">Setup progress</span>
18843 <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
18844 </div>
18845 <div class="wizard-progress-track">
18846 <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
18847 </div>
18848 </div>
18849 </div>
18850 </div>
18851 <div class="card-body">
18852 <form method="post" action="/analyze" id="analyze-form">
18853 <div class="wizard-step active" data-step="1">
18854 <div class="section">
18855 <div class="section-kicker">Step 1</div>
18856 <h2>Select project and preview scope</h2>
18857 <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
18858 <div class="field">
18859 <label for="path">Project path</label>
18860 {% if !git_repo.is_empty() %}
18861 <div class="git-source-banner">
18862 <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>
18863 Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
18864 <a href="/git-browser">← Back to Git Browser</a>
18865 </div>
18866 {% endif %}
18867 <div class="path-scope-grid">
18868 {% if !git_repo.is_empty() %}
18869 <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
18870 <input type="hidden" name="git_repo" value="{{ git_repo }}" />
18871 <input type="hidden" name="git_ref" value="{{ git_ref }}" />
18872 {% else %}
18873 <input id="path" name="path" type="text" value="testing/fixtures/basic" placeholder="/path/to/repository" required />
18874 <button type="button" class="mini-button oxide" id="browse-path">{% if server_mode %}Upload{% else %}Browse{% endif %}</button>
18875 <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
18876 {% endif %}
18877 <div class="path-scope-sep"></div>
18878 <div class="scope-legend-row">
18879 <span class="scope-legend-label">Scope legend:</span>
18880 <span class="scope-legend-badges">
18881 <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
18882 <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
18883 <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
18884 </span>
18885 </div>
18886 </div>
18887 {% if git_repo.is_empty() %}
18888 {% if server_mode %}
18889 <div id="upload-limit-tip" class="hint" style="margin-top:6px;font-size:11px;">
18890 ℹ️ Files are compressed and streamed — no fixed size limit.
18891 </div>
18892 {% endif %}
18893 <div class="path-info-row">
18894 <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
18895 <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/></svg>
18896 <span id="project-size-text">Project size: —</span>
18897 </button>
18898 </div>
18899 {% else %}
18900 <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
18901 {% endif %}
18902 <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
18903 <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
18904 </div>
18905
18906 <div class="scope-preview-divider" aria-hidden="true"></div>
18907
18908 <div id="preview-panel">
18909 <div class="preview-error">Loading preview...</div>
18910 </div>
18911 </div>
18912
18913 <div class="section" style="margin-top:14px;">
18914 <div class="preset-inline-row git-inline-row">
18915 <div class="toggle-card" style="margin:0;">
18916 <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
18917 <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
18918 <label class="checkbox">
18919 <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
18920 <div>
18921 <span>Detect and separate git submodules</span>
18922 <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
18923 </div>
18924 </label>
18925 </div>
18926 <div class="explainer-card prominent" style="margin:0;">
18927 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
18928 <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>
18929 <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
18930 path = libs/core
18931 url = https://github.com/org/core.git
18932
18933[submodule "libs/ui"]
18934 path = libs/ui
18935 url = https://github.com/org/ui.git</div>
18936 </div>
18937 </div>
18938 </div>
18939
18940 <div class="section">
18941 <div class="field-grid">
18942 <div class="field">
18943 <div class="glob-label-row">
18944 <label for="include_globs" style="margin:0;flex-shrink:0;">Include globs <span class="lbl-opt">— optional</span></label>
18945 <div id="include-scope-badge" class="include-scope-badge scope-all" aria-live="polite" style="margin:0;padding:4px 10px;font-size:11px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg> All files eligible — no include filter active</div>
18946 </div>
18947 <textarea id="include_globs" name="include_globs" class="glob-textarea" placeholder="Leave blank to scan everything Or narrow scope with patterns: src/**/*.py lib/**/*.js scripts/*.sh"></textarea>
18948 <div class="hint"><strong>Leave blank to scan everything</strong> under the project path. Only add patterns here when you want to limit the scan to specific folders or file types. Patterns are line- or comma-separated and relative to the project path.</div>
18949 </div>
18950 <div class="field">
18951 <div class="glob-label-row">
18952 <label for="exclude_globs" style="margin:0;flex-shrink:0;">Exclude globs</label>
18953 </div>
18954 <textarea id="exclude_globs" name="exclude_globs" class="glob-textarea" placeholder="examples: vendor/** **/*.min.js"></textarea>
18955 <div id="quick-exclude-chips" class="quick-excl-row">
18956 <span class="quick-excl-label">Quick add:</span>
18957 <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
18958 <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
18959 <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
18960 <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
18961 <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
18962 <button type="button" class="quick-excl-chip quick-excl-chip-all" data-pattern="third_party/** vendor/** node_modules/** build/** target/** dist/**">⚡ Skip all deps</button>
18963 </div>
18964 <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>
18965 </div>
18966 </div>
18967 <div class="glob-guidance-grid">
18968 <div class="glob-guidance-card">
18969 <strong>How to read them</strong>
18970 <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>
18971 </div>
18972 <div class="glob-guidance-card">
18973 <strong>Common include examples</strong>
18974 <p><strong>Empty (default)</strong> — scans everything. <code>src/**/*.rs</code> only Rust sources, <code>scripts/*</code> top-level scripts only, <code>tests/**</code> everything under tests.</p>
18975 </div>
18976 <div class="glob-guidance-card">
18977 <strong>Common exclude examples</strong>
18978 <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
18979 </div>
18980 </div>
18981 </div>
18982
18983 <div class="section" style="margin-top:14px;">
18984 <div class="preset-inline-row git-inline-row">
18985 <div class="toggle-card" style="margin:0;">
18986 <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
18987 <h4 style="margin:0 0 12px;font-size:16px;">Code Coverage file <span style="font-weight:400;color:var(--muted);font-size:13px;">(optional)</span></h4>
18988 <div class="field" style="margin:0;">
18989 <div class="input-group compact">
18990 <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
18991 <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
18992 </div>
18993 <div class="hint" style="margin-top:8px;">When provided, line, function, and branch coverage percentages are overlaid on each file in the report and shown on the Test Metrics page.</div>
18994 <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
18995 </div>
18996 </div>
18997 <div class="explainer-card prominent" style="margin:0;">
18998 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
18999 <div class="advanced-rule-description"><strong>Purpose:</strong> Overlay line, function, and branch coverage on each file in the HTML report and populate the Test Metrics dashboard.<br /><strong>Good default when:</strong> your test suite emits a coverage report in one of the supported formats.<br /><strong>Leave blank when:</strong> you only need SLOC totals without coverage data.</div>
19000 <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
19001lcov --capture --directory . --output-file coverage/lcov.info
19002
19003# C / C++ — llvm-cov (LCOV)
19004llvm-profdata merge -sparse default.profraw -o default.profdata
19005llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
19006
19007# C# — coverlet (Cobertura XML)
19008dotnet test --collect:"XPlat Code Coverage"
19009
19010# Python — pytest-cov (Cobertura XML)
19011pytest --cov --cov-report=xml
19012
19013# Python — coverage.py native JSON
19014coverage run -m pytest && coverage json # writes coverage.json
19015
19016# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
19017./gradlew jacocoTestReport</div>
19018 </div>
19019 </div>
19020 </div>
19021
19022 <div class="wizard-actions">
19023 <div class="left"></div>
19024 <div class="right">
19025 <div id="preview-gate-status" class="preview-gate-status" aria-live="polite" style="display:none;">
19026 <span class="preview-gate-spinner" aria-hidden="true"></span>
19027 <span class="preview-gate-text">Scanning project scope…</span>
19028 <button type="button" class="preview-gate-info" id="preview-gate-info" title="What is this? Jump up to the live scope preview" aria-label="Show what is being scanned — jump to the scope preview">
19029 <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/></svg>
19030 </button>
19031 </div>
19032 <button type="button" class="secondary next-step" id="step1-next" data-next="2">Next: Counting rules</button>
19033 </div>
19034 </div>
19035 </div>
19036
19037 <div class="default-path-overlay" id="default-path-overlay" role="dialog" aria-modal="true" aria-labelledby="default-path-title">
19038 <div class="default-path-modal">
19039 <h3 id="default-path-title">
19040 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 9v4"/><path d="M12 17h.01"/><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/></svg>
19041 Proceed with the default sample test?
19042 </h3>
19043 <p>The <strong>Project path</strong> is still set to the bundled sample <code>testing/fixtures/basic</code></p>
19044 <p>You haven't selected your own project yet.</p>
19045 <p>Make sure to fill out the <strong>Project path</strong> with your repository and confirm it uploads successfully before scanning.</p>
19046 <div class="default-path-actions">
19047 <button type="button" class="secondary prev-step" id="default-path-cancel">Fill in project path</button>
19048 <button type="button" class="secondary next-step" id="default-path-proceed">Proceed with sample</button>
19049 </div>
19050 </div>
19051 </div>
19052
19053 <div class="wizard-step" data-step="2">
19054 <div class="section">
19055 <div class="section-kicker">Step 2</div>
19056 <h2>Choose counting behavior</h2>
19057 <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>
19058<div class="subsection-bar">Primary line classification</div>
19059 <div class="preset-kv-row">
19060 <div class="toggle-card mixed-line-card" style="margin:0;">
19061 <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
19062 <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
19063 <select id="mixed_line_policy" name="mixed_line_policy">
19064 <option value="code_only">Code only</option>
19065 <option value="code_and_comment">Code and comment</option>
19066 <option value="comment_only">Comment only</option>
19067 <option value="separate_mixed_category">Separate mixed category</option>
19068 </select>
19069 <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
19070 </div>
19071 <div class="explainer-card prominent" style="margin:0;">
19072 <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
19073 <div class="explainer-body" id="mixed-policy-description"></div>
19074 <div class="code-sample" id="mixed-policy-example"></div>
19075 </div>
19076 </div>
19077 </div>
19078
19079 <div class="subsection-bar">Additional scan rules</div>
19080 <div class="scan-rules-grid">
19081 <div class="preset-inline-row">
19082 <div class="toggle-card" style="margin:0;">
19083 <div class="field-help-title">Generated files</div>
19084 <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
19085 <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
19086 </div>
19087 <div class="explainer-card prominent" style="margin:0;">
19088 <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>
19089 <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
19090# Files matching codegen patterns are excluded:
19091# *.generated.cs *.pb.go *.g.dart</div>
19092 </div>
19093 </div>
19094 <div class="preset-inline-row">
19095 <div class="toggle-card" style="margin:0;">
19096 <div class="field-help-title">Minified files</div>
19097 <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
19098 <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
19099 </div>
19100 <div class="explainer-card prominent" style="margin:0;">
19101 <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>
19102 <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
19103# Heuristic: very long lines + low whitespace ratio
19104# jquery.min.js bundle.min.css → skipped</div>
19105 </div>
19106 </div>
19107 <div class="preset-inline-row">
19108 <div class="toggle-card" style="margin:0;">
19109 <div class="field-help-title">Vendor directories</div>
19110 <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
19111 <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
19112 </div>
19113 <div class="explainer-card prominent" style="margin:0;">
19114 <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>
19115 <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
19116# Directories named vendor/ node_modules/ third_party/
19117# → entire subtree is excluded from totals</div>
19118 </div>
19119 </div>
19120 <div class="preset-inline-row">
19121 <div class="toggle-card" style="margin:0;">
19122 <div class="field-help-title">Lockfiles and manifests</div>
19123 <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
19124 <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
19125 </div>
19126 <div class="explainer-card prominent" style="margin:0;">
19127 <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>
19128 <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false (default)
19129# Files like package-lock.json Cargo.lock yarn.lock
19130# → skipped unless this is enabled</div>
19131 </div>
19132 </div>
19133 <div class="preset-inline-row">
19134 <div class="toggle-card" style="margin:0;">
19135 <div class="field-help-title">Binary handling</div>
19136 <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
19137 <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>
19138 </div>
19139 <div class="explainer-card prominent" style="margin:0;">
19140 <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>
19141 <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip" (default)
19142# Detected via long lines + low whitespace heuristic
19143# .png .exe .so → skipped silently</div>
19144 </div>
19145 </div>
19146 <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
19147 <div class="toggle-card" style="margin:0;">
19148 <div class="field-help-title">Python docstrings</div>
19149 <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
19150 <label class="checkbox">
19151 <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
19152 <span>Count as comment-style lines</span>
19153 </label>
19154 </div>
19155 <div class="explainer-card prominent" style="margin:0;">
19156 <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>
19157 <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
19158 </div>
19159 </div>
19160 </div>
19161 <div class="subsection-bar">IEEE 1045-1992 counting</div>
19162 <div class="scan-rules-grid">
19163 <div class="preset-inline-row">
19164 <div class="toggle-card" style="margin:0;">
19165 <div class="field-help-title">Continuation lines</div>
19166 <h4 style="margin:6px 0 12px;font-size:16px;">Continuation-line policy</h4>
19167 <select name="continuation_line_policy" id="continuation_line_policy">
19168 <option value="each_physical_line" selected>Each physical line (default)</option>
19169 <option value="collapse_to_logical">Collapse to logical line</option>
19170 </select>
19171 </div>
19172 <div class="explainer-card prominent" style="margin:0;">
19173 <div class="advanced-rule-description"><strong>Purpose:</strong> Controls how backslash-continued lines (C macros, shell, Makefile) are counted.<br /><strong>Each physical line</strong> — the IEEE 1045-1992 default; every line with content is counted separately.<br /><strong>Collapse to logical</strong> — a backslash-continued sequence counts as one logical line, matching logical-SLOC conventions.</div>
19174 <div class="code-sample" style="margin-top:10px;font-size:12px;">#define MAX(a, b) \
19175 ((a) > (b) ? (a) : (b))
19176# each_physical_line → 2 SLOC
19177# collapse_to_logical → 1 SLOC</div>
19178 </div>
19179 </div>
19180 <div class="preset-inline-row">
19181 <div class="toggle-card" style="margin:0;">
19182 <div class="field-help-title">Block-comment blanks</div>
19183 <h4 style="margin:6px 0 12px;font-size:16px;">Blank lines in block comments</h4>
19184 <select name="blank_in_block_comment_policy" id="blank_in_block_comment_policy">
19185 <option value="count_as_comment" selected>Count as comment (default)</option>
19186 <option value="count_as_blank">Count as blank</option>
19187 </select>
19188 </div>
19189 <div class="explainer-card prominent" style="margin:0;">
19190 <div class="advanced-rule-description"><strong>Purpose:</strong> Decides how blank lines that fall inside a <code style="font-size:12px;">/* … */</code> block comment are classified.<br /><strong>Count as comment</strong> — IEEE-aligned; blank lines are part of the comment body.<br /><strong>Count as blank</strong> — legacy behaviour; blank lines inside block comments are treated as ordinary blank lines.</div>
19191 <div class="code-sample" style="margin-top:10px;font-size:12px;">/*
19192 * Summary line
19193 * ← blank inside block comment
19194 * Detail line
19195 */
19196# count_as_comment → blank counts toward comments
19197# count_as_blank → blank counts toward blanks</div>
19198 </div>
19199 </div>
19200 <div class="preset-inline-row">
19201 <div class="toggle-card" style="margin:0;">
19202 <div class="field-help-title">Compiler directives</div>
19203 <h4 style="margin:6px 0 12px;font-size:16px;">Count compiler directives</h4>
19204 <select name="count_compiler_directives" id="count_compiler_directives">
19205 <option value="enabled" selected>Include in code SLOC (default)</option>
19206 <option value="disabled">Exclude from code SLOC</option>
19207 </select>
19208 </div>
19209 <div class="explainer-card prominent" style="margin:0;">
19210 <div class="advanced-rule-description"><strong>Purpose:</strong> IEEE 1045-1992 §4.2 — controls whether preprocessor directives contribute to code SLOC. Applies to C, C++, and Objective-C.<br /><strong>Include</strong> — <code style="font-size:12px;">#include</code> / <code style="font-size:12px;">#define</code> lines count toward code SLOC (default).<br /><strong>Exclude</strong> — directives are tracked separately in raw counts but not added to effective code SLOC; useful when comparing with tools that strip the preprocessor layer.</div>
19211 <div class="code-sample" style="margin-top:10px;font-size:12px;">#include <stdio.h> ← compiler directive
19212#define BUF 256 ← compiler directive
19213int main() { … } ← code
19214# enabled → 3 code SLOC
19215# disabled → 1 code SLOC + 2 directive lines</div>
19216 </div>
19217 </div>
19218 </div>
19219
19220 <div class="subsection-bar">Code Style Analysis</div>
19221 <div class="scan-rules-grid">
19222 <div class="preset-inline-row">
19223 <div class="toggle-card" style="margin:0;">
19224 <div class="field-help-title">Style analysis</div>
19225 <h4 style="margin:6px 0 12px;font-size:16px;">Enable style analysis</h4>
19226 <select name="style_analysis_enabled" id="style_analysis_enabled">
19227 <option value="enabled" selected>Enabled (default)</option>
19228 <option value="disabled">Disabled — skip style scoring</option>
19229 </select>
19230 </div>
19231 <div class="explainer-card prominent" style="margin:0;">
19232 <div class="advanced-rule-description"><strong>Purpose:</strong> Controls whether lexical style-guide heuristics run at all.<br /><strong>Enable</strong> — every supported file is scored against its language's style guides and the results appear in the report (default).<br /><strong>Disable</strong> — style scoring is skipped entirely; useful for very large repos where you only need SLOC counts.</div>
19233 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_analysis_enabled = true (default)
19234# style_analysis_enabled = false (skip, faster scan)
19235# Disabling removes the Code Style section from the report.</div>
19236 </div>
19237 </div>
19238 <div class="preset-inline-row">
19239 <div class="toggle-card" style="margin:0;">
19240 <div class="field-help-title">Column-width threshold</div>
19241 <h4 style="margin:6px 0 12px;font-size:16px;">Line-length compliance column</h4>
19242 <select name="style_col_threshold" id="style_col_threshold">
19243 <option value="80" selected>80 columns (PEP 8, Google, gofmt)</option>
19244 <option value="100">100 columns (Uber Go, Google Java)</option>
19245 <option value="120">120 columns (Uber Go max, Kotlin)</option>
19246 </select>
19247 </div>
19248 <div class="explainer-card prominent" style="margin:0;">
19249 <div class="advanced-rule-description"><strong>Purpose:</strong> Sets the column width used to compute the <em>N-col Compliant</em> summary chip in the Code Style Analysis section of the report.<br /><strong>A file is compliant</strong> when ≤ 5 % of its lines exceed this limit.<br /><strong>Does not affect SLOC counts</strong> — only the style-adherence reporting. The style guide scores themselves are always computed across all three thresholds (80 / 100 / 120) regardless of this setting.</div>
19250 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_col_threshold = 80 (PEP 8, Google, gofmt)
19251# style_col_threshold = 100 (Uber Go, Google Java)
19252# style_col_threshold = 120 (Uber Go max, Kotlin)
19253# Files where <= 5% of lines exceed the limit
19254# are counted as "N-col compliant" in the report.</div>
19255 </div>
19256 </div>
19257 <div class="preset-inline-row">
19258 <div class="toggle-card" style="margin:0;">
19259 <div class="field-help-title">Score alert threshold</div>
19260 <h4 style="margin:6px 0 12px;font-size:16px;">Low-score file alert</h4>
19261 <select name="style_score_threshold" id="style_score_threshold">
19262 <option value="0" selected>Off — no threshold (default)</option>
19263 <option value="40">40% — flag poorly styled files</option>
19264 <option value="50">50% — flag below-average files</option>
19265 <option value="60">60% — flag below-good files</option>
19266 <option value="70">70% — flag below-strong files</option>
19267 </select>
19268 </div>
19269 <div class="explainer-card prominent" style="margin:0;">
19270 <div class="advanced-rule-description"><strong>Purpose:</strong> Files whose dominant-guide adherence score falls below this percentage are highlighted with a red left-border in the per-file style table — making it easy to spot the lowest-conformance files at a glance.<br /><strong>Off</strong> — all files shown without any alert (default).<br /><strong>Any other value</strong> — a red indicator flags each file scoring below the threshold.</div>
19271 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_score_threshold = 0 (off, default)
19272# style_score_threshold = 50 (flag files < 50%)
19273# Low-scoring files get a red left-border in the
19274# per-file style breakdown table.</div>
19275 </div>
19276 </div>
19277 </div>
19278
19279 <div class="always-tracked-tip">
19280 <div class="always-tracked-tip-icon">ℹ</div>
19281 <div class="always-tracked-tip-body">
19282 <div class="field-help-title">Always tracked — not configurable · What these settings change</div>
19283 <h4>Comment and blank-line basics & Lines on the boundary</h4>
19284 <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 settings on this page only affect lines that live on the boundary between code and comments — for example <code style="font-size:12px;">x = 1 # counter</code>, which contains both executable code and inline comment text. Every other category is always counted the same regardless of these settings.</div>
19285 </div>
19286 </div>
19287
19288 <div class="subsection-bar">Advanced Metrics</div>
19289 <div class="scan-rules-grid">
19290 <div class="preset-inline-row">
19291 <div class="toggle-card" style="margin:0;">
19292 <div class="field-help-title">COCOMO mode</div>
19293 <h4 style="margin:6px 0 12px;font-size:16px;">Cost estimation model</h4>
19294 <select name="cocomo_mode" id="cocomo_mode">
19295 <option value="organic" selected>Organic — small team, familiar domain (default)</option>
19296 <option value="semi_detached">Semi-detached — mixed constraints</option>
19297 <option value="embedded">Embedded — tight hardware/OS constraints</option>
19298 </select>
19299 </div>
19300 <div class="explainer-card prominent" style="margin:0;">
19301 <div class="advanced-rule-description"><strong>Purpose:</strong> Selects the COCOMO I Basic mode used to estimate development effort, schedule, and team size from code SLOC.<br /><strong>Organic</strong> — small teams with good experience on similar problems (most software projects).<br /><strong>Semi-detached</strong> — mixed experience; some novel aspects; medium-sized projects.<br /><strong>Embedded</strong> — tight hardware, OS, or real-time constraints; high innovation; large projects.</div>
19302 <div class="code-sample" style="margin-top:10px;font-size:12px;"># Organic: Effort = 2.4 × KSLOC^1.05
19303# Semi-detached: Effort = 3.0 × KSLOC^1.12
19304# Embedded: Effort = 3.6 × KSLOC^1.20
19305# All modes: Schedule = 2.5 × Effort^d</div>
19306 </div>
19307 </div>
19308 <div class="preset-inline-row">
19309 <div class="toggle-card" style="margin:0;">
19310 <div class="field-help-title">Complexity alert</div>
19311 <h4 style="margin:6px 0 12px;font-size:16px;">Complexity score alert threshold</h4>
19312 <input type="number" name="complexity_alert" id="complexity_alert" min="0" max="9999" placeholder="e.g. 100 — leave blank for no alert" style="width:100%;padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--text);font-size:14px;" />
19313 </div>
19314 <div class="explainer-card prominent" style="margin:0;">
19315 <div class="advanced-rule-description"><strong>Purpose:</strong> When set, files whose total cyclomatic complexity score exceeds this threshold are highlighted in the results page with an accent border.<br /><strong>Complexity score</strong> counts branch decision keywords (if, for, while, ||, &&, …) across all code lines — a fast lexical approximation of McCabe complexity.<br /><strong>Common thresholds:</strong> 50 for a simple project, 100–200 for medium, 300+ for large repos.</div>
19316 <div class="code-sample" style="margin-top:10px;font-size:12px;"># 0 or blank = no alert (default)
19317# 50 = flag any file with > 50 branch points
19318# 100 = flag any file with > 100 branch points
19319# Files above the threshold are highlighted
19320# in the result page metric strip.</div>
19321 </div>
19322 </div>
19323 <div class="preset-inline-row">
19324 <div class="toggle-card" style="margin:0;">
19325 <div class="field-help-title">Git hotspots</div>
19326 <h4 style="margin:6px 0 12px;font-size:16px;">Activity window (days)</h4>
19327 <input type="number" name="activity_window" id="activity_window" min="0" max="3650" value="90" placeholder="e.g. 90 — set 0 to disable" style="width:100%;padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--text);font-size:14px;" />
19328 </div>
19329 <div class="explainer-card prominent" style="margin:0;">
19330 <div class="advanced-rule-description"><strong>Purpose:</strong> <strong>On by default (90 days).</strong> oxide-sloc runs a single <code>git log</code> pass over the last N days and ranks files by <strong>code lines × recent commits</strong> in a Git Hotspots table — large files that change often are the strongest refactoring candidates.<br /><strong>Requires</strong> the scanned path to be a git repository. This is distinct from the scan-to-scan churn rate shown on the Compare page.</div>
19331 <div class="code-sample" style="margin-top:10px;font-size:12px;"># 90 = last quarter (default)
19332# 30 = last month of activity
19333# 365 = last year
19334# 0 = disable the hotspots table
19335# Adds Commits + Last-changed columns to CSV.</div>
19336 </div>
19337 </div>
19338 <div class="preset-inline-row">
19339 <div class="toggle-card" style="margin:0;">
19340 <div class="field-help-title">Duplicate handling</div>
19341 <h4 style="margin:6px 0 12px;font-size:16px;">Duplicate file detection</h4>
19342 <select name="exclude_duplicates" id="exclude_duplicates">
19343 <option value="disabled" selected>Detect and report only (default)</option>
19344 <option value="enabled">Detect and exclude from SLOC totals</option>
19345 </select>
19346 </div>
19347 <div class="explainer-card prominent" style="margin:0;">
19348 <div class="advanced-rule-description"><strong>Purpose:</strong> Detects files with identical content (bit-for-bit copies) that would otherwise inflate SLOC counts.<br /><strong>Detect and report only</strong> — duplicates are counted normally in totals; a "Duplicate groups" chip in the result page shows how many groups exist (default).<br /><strong>Detect and exclude</strong> — only one file per identical-content group contributes to code/comment/blank line totals; the rest are silently excluded.</div>
19349 <div class="code-sample" style="margin-top:10px;font-size:12px;"># A repo with 3 identical config files:
19350# detect only → all 3 counted in SLOC
19351# exclude dupes → 1 counted, 2 excluded
19352# Duplicate groups chip always shows the count.</div>
19353 </div>
19354 </div>
19355 <div class="always-tracked-tip" style="margin:8px 0 0;">
19356 <div class="always-tracked-tip-icon">ℹ</div>
19357 <div class="always-tracked-tip-body">
19358 <div class="field-help-title">Always computed — every scan produces these automatically</div>
19359 <div class="always-tracked-metrics-row">
19360 <div><strong>Cyclomatic complexity</strong>Counts branch keywords per file.</div>
19361 <div><strong>Logical SLOC</strong>Executable statements — C-family, Python, Ruby, Shell & more.</div>
19362 <div><strong>ULOC & DRYness</strong>De-duplicates lines project-wide; DRYness % = ULOC ÷ Code Lines.</div>
19363 <div><strong>COCOMO I</strong>Converts total SLOC into effort, schedule & team-size estimates.</div>
19364 </div>
19365 <div class="hint" style="margin-top:8px;">All four appear in the results page. The settings above only affect how they are displayed or whether edge cases are excluded.</div>
19366 </div>
19367 </div>
19368 </div>
19369
19370 <div class="wizard-actions">
19371 <div class="left">
19372 <button type="button" class="secondary prev-step" data-prev="1">Back</button>
19373 </div>
19374 <div class="right">
19375 <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
19376 </div>
19377 </div>
19378 </div>
19379
19380 <div class="wizard-step" data-step="3">
19381 <div class="section">
19382 <div class="section-kicker">Step 3</div>
19383 <h2>Output and report identity</h2>
19384 <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>
19385 <div class="preset-kv-row">
19386 <div class="toggle-card" style="margin:0;">
19387 <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
19388 <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
19389 <select id="scan_preset">
19390 <option value="balanced">Balanced local scan</option>
19391 <option value="code_focused">Code focused</option>
19392 <option value="comment_audit">Comment audit</option>
19393 <option value="deep_review">Deep review</option>
19394 </select>
19395 <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
19396 </div>
19397 <div class="explainer-card">
19398 <div class="field-help-title">Selected scan preset</div>
19399 <div class="explainer-body" id="scan-preset-description"></div>
19400 <div class="preset-summary-row" id="scan-preset-summary"></div>
19401 <div class="code-sample" id="scan-preset-example"></div>
19402 <div class="preset-note" id="scan-preset-note"></div>
19403 </div>
19404 </div>
19405 <hr class="step3-separator" />
19406 <div class="preset-kv-row">
19407 <div class="toggle-card" style="margin:0;">
19408 <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
19409 <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
19410 <select id="artifact_preset">
19411 <option value="review">Review bundle</option>
19412 <option value="full">Full bundle</option>
19413 <option value="html_only">HTML only</option>
19414 <option value="machine">Machine bundle</option>
19415 </select>
19416 <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
19417 </div>
19418 <div class="explainer-card">
19419 <div class="field-help-title">Selected artifact preset</div>
19420 <div class="explainer-body" id="artifact-preset-description"></div>
19421 <div class="preset-summary-row" id="artifact-preset-summary"></div>
19422 <div class="code-sample" id="artifact-preset-example"></div>
19423 </div>
19424 </div>
19425 </div>
19426
19427 <div class="section section-spacer-top">
19428 <div class="output-field-row">
19429 <div class="field">
19430 <label for="output_dir">Output directory</label>
19431 {% if server_mode %}
19432 <div class="input-group compact">
19433 <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" readonly style="cursor:default;opacity:0.68;background:var(--surface-2);" />
19434 </div>
19435 <div class="hint">Output path is managed by the server — each run stores artifacts in a unique timestamped subfolder automatically.</div>
19436 {% else %}
19437 <div class="input-group compact">
19438 <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
19439 <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
19440 <button type="button" class="mini-button" id="use-default-output">Use default</button>
19441 </div>
19442 <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
19443 {% endif %}
19444 </div>
19445 <div class="output-field-aside">
19446 <strong>Where reports land</strong>
19447 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.
19448 </div>
19449 </div>
19450 </div>
19451
19452 <div class="section section-spacer-top">
19453 <div class="output-field-row">
19454 <div class="field">
19455 <label for="report_title">Report title</label>
19456 <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
19457 <div class="hint">Appears in HTML and PDF output headers.</div>
19458 </div>
19459 <div class="output-field-aside">
19460 <strong>Shown in exported artifacts</strong>
19461 This title is embedded in the HTML and PDF reports and stays visible in the tool header while you configure the run. It defaults to the last folder name of the selected project path.
19462 </div>
19463 </div>
19464 </div>
19465
19466 <div class="section section-spacer-top">
19467 <div class="output-field-row">
19468 <div class="field">
19469 <label for="report_header_footer">Report header / footer</label>
19470 <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
19471 <div class="hint" style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">Printed on every HTML/PDF page — company name, project ID, or scanner tag.</div>
19472 </div>
19473 <div class="output-field-aside">
19474 <strong>Page-level identification</strong>
19475 This text appears as a thin banner at the top and bottom of every report page. Leave blank to omit. Useful for labeling reports with an organization name, engagement ID, or classification level.
19476 </div>
19477 </div>
19478 </div>
19479
19480 <div class="wizard-actions">
19481 <div class="left">
19482 <button type="button" class="secondary prev-step" data-prev="2">Back</button>
19483 </div>
19484 <div class="right">
19485 <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
19486 </div>
19487 </div>
19488 </div>
19489
19490 <div class="wizard-step" data-step="4">
19491 <div class="section">
19492 <div class="section-kicker">Step 4</div>
19493 <h2>Review selections and run</h2>
19494 <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
19495 <div class="review-grid">
19496 <div class="review-card highlight">
19497 <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>
19498 <ul id="review-scan-summary"></ul>
19499 </div>
19500 <div class="review-card highlight">
19501 <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>
19502 <ul id="review-count-summary"></ul>
19503 </div>
19504 <div class="review-card">
19505 <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>
19506 <ul id="review-artifact-summary"></ul>
19507 <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
19508 </div>
19509 <div class="review-card">
19510 <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>
19511 <ul id="review-preview-summary"></ul>
19512 </div>
19513 </div>
19514 </div>
19515
19516 <div class="wizard-actions">
19517 <div class="left">
19518 <button type="button" class="secondary prev-step" data-prev="3">Back</button>
19519 </div>
19520 <div class="right">
19521 <button type="submit" id="submit-button" class="primary">Run analysis</button>
19522 </div>
19523 </div>
19524 </div>
19525 {% if server_mode %}
19526 <input type="file" id="dir-upload-input" webkitdirectory multiple style="display:none" aria-hidden="true">
19527 <input type="file" id="cov-upload-input" accept=".info,.lcov,.xml,.json" style="display:none" aria-hidden="true">
19528 {% endif %}
19529 </form>
19530 </div>
19531 </section>
19532 </div>
19533 </div>
19534
19535 <script nonce="{{ csp_nonce }}">
19536 (function () {
19537 function startScanPhase() {
19538 var phaseEl = document.getElementById("scan-phase");
19539 if (!phaseEl) return;
19540 var phases = [
19541 "Discovering files...",
19542 "Decoding file encodings...",
19543 "Detecting languages...",
19544 "Analyzing source lines...",
19545 "Applying counting policies...",
19546 "Aggregating results...",
19547 "Rendering report..."
19548 ];
19549 var durations = [800, 600, 1200, 3000, 1000, 800, 600];
19550 var i = 0;
19551 function next() {
19552 phaseEl.style.opacity = "0";
19553 setTimeout(function () {
19554 phaseEl.textContent = phases[i];
19555 phaseEl.style.opacity = "0.85";
19556 var delay = durations[i] || 1800;
19557 i++;
19558 if (i < phases.length) { setTimeout(next, delay); }
19559 }, 200);
19560 }
19561 next();
19562 }
19563
19564 var form = document.getElementById("analyze-form");
19565 var loading = document.getElementById("loading");
19566 var submitButton = document.getElementById("submit-button");
19567 var pathInput = document.getElementById("path");
19568 var GIT_MODE = !!(pathInput && pathInput.readOnly);
19569 var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
19570 var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
19571 var outputDirInput = document.getElementById("output_dir");
19572 var reportTitleInput = document.getElementById("report_title");
19573 var previewPanel = document.getElementById("preview-panel");
19574 var refreshButton = document.getElementById("refresh-preview");
19575 var refreshPreviewInline = document.getElementById("refresh-preview-inline");
19576 var useSamplePath = document.getElementById("use-sample-path");
19577 var useDefaultOutput = document.getElementById("use-default-output");
19578 var browsePath = document.getElementById("browse-path");
19579 var browseOutputDir = document.getElementById("browse-output-dir");
19580 var browseCoverage = document.getElementById("browse-coverage");
19581 var coverageInput = document.getElementById("coverage_file");
19582 var covScanStatus = document.getElementById("cov-scan-status");
19583 var coverageSuggestTimer = null;
19584 var covAutoFilled = false;
19585 var SERVER_MODE = {% if server_mode %}true{% else %}false{% endif %};
19586
19587 // Scroll long path inputs to end on blur (replaces inline onblur="..." removed for CSP).
19588 (function() {
19589 var ids = ["path", "output_dir"];
19590 ids.forEach(function(id) {
19591 var el = document.getElementById(id);
19592 if (el) el.addEventListener("blur", function() { this.scrollLeft = this.scrollWidth; });
19593 });
19594 }());
19595 function fmtBytes(b) {
19596 b = Number(b) || 0;
19597 if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GB';
19598 if (b >= 1048576) return (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
19599 if (b >= 1024) return Math.round(b / 1024) + ' KB';
19600 return b + ' B';
19601 }
19602 var themeToggle = document.getElementById("theme-toggle");
19603
19604 function showBannerToast(msg, isError, opts) {
19605 opts = opts || {};
19606 var t = document.createElement('div');
19607 t.className = isError ? 'toast-error' : 'toast-success';
19608 var topPos = opts.top ? '80px' : null;
19609 t.style.cssText = 'position:fixed;' + (topPos ? 'top:' + topPos + ';' : 'bottom:24px;') +
19610 'left:50%;transform:translateX(-50%);z-index:9999;min-width:320px;max-width:560px;' +
19611 'box-shadow:0 8px 32px rgba(0,0,0,0.22);padding:14px 20px;border-radius:12px;' +
19612 'font-size:13px;font-weight:600;line-height:1.5;text-align:center;';
19613 if (opts.icon) {
19614 var inner = document.createElement('span');
19615 inner.innerHTML = opts.icon + ' ';
19616 t.appendChild(inner);
19617 }
19618 t.appendChild(document.createTextNode(msg));
19619 document.body.appendChild(t);
19620 setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5500);
19621 }
19622 var mixedLinePolicy = document.getElementById("mixed_line_policy");
19623 var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
19624 var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
19625 var scanPreset = document.getElementById("scan_preset");
19626 var artifactPreset = document.getElementById("artifact_preset");
19627 var includeGlobsInput = document.getElementById("include_globs");
19628 var excludeGlobsInput = document.getElementById("exclude_globs");
19629
19630 // Include globs scope badge — updates reactively as the user types.
19631 (function() {
19632 var badge = document.getElementById("include-scope-badge");
19633 if (!badge || !includeGlobsInput) return;
19634 var iconCheck = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg> ';
19635 var iconFilter = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon></svg> ';
19636 function update() {
19637 var val = includeGlobsInput.value.trim();
19638 if (!val) {
19639 badge.className = "include-scope-badge scope-all";
19640 badge.innerHTML = iconCheck + "All files eligible \u2014 no include filter active";
19641 } else {
19642 var count = val.split(/[\n,]+/).filter(function(s) { return s.trim(); }).length;
19643 badge.className = "include-scope-badge scope-narrow";
19644 badge.innerHTML = iconFilter + "Scoped to " + count + " pattern" + (count === 1 ? "" : "s") + " \u2014 only matching files will be included";
19645 }
19646 }
19647 includeGlobsInput.addEventListener("input", update);
19648 update();
19649 }());
19650
19651 // Quick-exclude chips — append pattern to exclude_globs textarea.
19652 document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
19653 chip.addEventListener("click", function() {
19654 var pattern = chip.getAttribute("data-pattern") || "";
19655 if (!pattern || !excludeGlobsInput) return;
19656 var current = excludeGlobsInput.value.trim();
19657 // For the "skip all" chip, replace any existing dep patterns cleanly.
19658 var patterns = pattern.split("\n");
19659 var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
19660 var added = false;
19661 patterns.forEach(function(p) {
19662 p = p.trim();
19663 if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
19664 });
19665 if (added) {
19666 excludeGlobsInput.value = lines.join("\n");
19667 excludeGlobsInput.dispatchEvent(new Event("input"));
19668 }
19669 chip.classList.add("active");
19670 });
19671 });
19672
19673 var liveReportTitle = document.getElementById("live-report-title");
19674 var navProjectPill = document.getElementById("nav-project-pill");
19675 var navProjectTitle = document.getElementById("nav-project-title");
19676 var reportTitlePreview = null;
19677 var wizardProgressFill = document.getElementById("wizard-progress-fill");
19678 var wizardProgressValue = document.getElementById("wizard-progress-value");
19679 var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
19680 var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
19681 var reportTitleTouched = false;
19682 var currentStep = 1;
19683 var previewTimer = null;
19684 var _previewGen = 0;
19685 // True while the scope preview (local) / project upload (server mode) is in
19686 // flight. The step 1 -> 2 "Next" button is blocked until it settles so the
19687 // user can't advance past a project whose scope/upload isn't ready yet.
19688 var previewLoading = false;
19689 // Set when the current preview reports multiple independent git repos under
19690 // the selected root. Advancing past step 1 is blocked until the user ticks
19691 // the acknowledgement checkbox (or re-selects a single repository).
19692 var multiRepoBlocked = false;
19693 function step1ForwardBlocked() {
19694 return previewLoading || multiRepoBlocked;
19695 }
19696 function refreshStep1Gate() {
19697 var nextBtn = document.getElementById("step1-next");
19698 if (nextBtn) {
19699 var blocked = step1ForwardBlocked();
19700 nextBtn.classList.toggle("is-blocked", blocked);
19701 nextBtn.setAttribute("aria-disabled", blocked ? "true" : "false");
19702 }
19703 }
19704 function setPreviewLoading(loading) {
19705 previewLoading = !!loading;
19706 var gate = document.getElementById("preview-gate-status");
19707 refreshStep1Gate();
19708 if (gate) {
19709 var txt = gate.querySelector(".preview-gate-text");
19710 if (txt) txt.textContent = SERVER_MODE
19711 ? "Uploading & scanning project…"
19712 : "Scanning project scope…";
19713 gate.style.display = previewLoading ? "flex" : "none";
19714 }
19715 }
19716 // Info button on the gate: scroll up to the live scope preview so the user
19717 // can see exactly what is being scanned (elapsed time + rotating status).
19718 var previewGateInfo = document.getElementById("preview-gate-info");
19719 if (previewGateInfo) {
19720 previewGateInfo.addEventListener("click", function () {
19721 var target = document.getElementById("preview-panel");
19722 if (!target) return;
19723 target.scrollIntoView({ behavior: "smooth", block: "center" });
19724 target.classList.add("preview-panel-flash");
19725 setTimeout(function () { target.classList.remove("preview-panel-flash"); }, 1400);
19726 });
19727 }
19728 var quickScanBtn = document.getElementById("quick-scan-btn");
19729
19730 function dismissAnalysisModal() {
19731 if (loading) loading.classList.remove("active");
19732 document.body.classList.remove("modal-open");
19733 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
19734 var el = document.getElementById(id);
19735 if (el) el.classList.add("hidden");
19736 });
19737 var cancelBtn = document.getElementById("lc-cancel-btn");
19738 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "\u2715 Cancel scan"; }
19739 var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
19740 var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
19741 var sd = document.getElementById("lc-stage-desc"); if (sd) sd.textContent = "Initializing language analyzers and loading configuration\u2026";
19742 for (var ri=1;ri<=4;ri++){var rs=document.getElementById("lc-step-"+ri);if(!rs)continue;rs.classList.remove("active","done");if(ri===1)rs.classList.add("active");}
19743 var rsc=document.getElementById("lc-speed-card");if(rsc)rsc.classList.add("hidden");
19744 var rcard = document.getElementById("loading-card"); if (rcard) rcard.classList.add("lc-pulsing");
19745 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
19746 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
19747 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19748 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19749 }
19750
19751 var lcDismissBtn = document.getElementById("lc-dismiss");
19752 if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
19753
19754 // When the browser restores this page from bfcache (Back button after navigating to results),
19755 // the loading overlay would still be showing its active state. Dismiss it immediately.
19756 window.addEventListener("pageshow", function(e) {
19757 if (e.persisted) { dismissAnalysisModal(); }
19758 });
19759
19760 function startAsyncAnalysis(formData) {
19761 var gitRepo = (formData.get("git_repo") || "").toString();
19762 var gitRef = (formData.get("git_ref") || "").toString();
19763 var pathVal = (gitRepo || (formData.get("path") || "")).toString();
19764 var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
19765
19766 var pathEl = document.getElementById("lc-path-text");
19767 if (pathEl) pathEl.textContent = displayPath;
19768
19769 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
19770 var el = document.getElementById(id);
19771 if (el) el.classList.add("hidden");
19772 });
19773 var cancelBtn = document.getElementById("lc-cancel-btn");
19774 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
19775 var startCard = document.getElementById("loading-card"); if (startCard) startCard.classList.add("lc-pulsing");
19776 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
19777 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
19778 var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
19779 var phase0 = document.getElementById("lc-phase"); if (phase0) phase0.textContent = "Starting";
19780 var sd0 = document.getElementById("lc-stage-desc"); if (sd0) sd0.textContent = "Initializing language analyzers and loading configuration\u2026";
19781 for (var si=1;si<=4;si++){var ss=document.getElementById("lc-step-"+si);if(!ss)continue;ss.classList.remove("active","done");if(si===1)ss.classList.add("active");}
19782 var sc0=document.getElementById("lc-speed-card");if(sc0)sc0.classList.add("hidden");
19783
19784 if (loading) loading.classList.add("active");
19785 document.body.classList.add("modal-open");
19786
19787 var startTime = Date.now();
19788 var elapsedTimer = setInterval(function() {
19789 var s = Math.floor((Date.now() - startTime) / 1000);
19790 var el = document.getElementById("lc-elapsed");
19791 if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
19792 }, 1000);
19793
19794 var warnShown = false, pollRetries = 0, activeWaitId = null, lastFd = 0, lastFdTime = Date.now();
19795
19796 function fmt(n){var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}
19797
19798 var PHASE_DESC = {
19799 'Starting': 'Initializing language analyzers and loading configuration\u2026',
19800 'Scanning files': 'Walking the directory tree, applying scope filters, and reading file bytes\u2026',
19801 'Running': 'Running the lexical state machine across all discovered source files\u2026',
19802 'Writing reports': 'Rendering the HTML report and saving JSON artifacts to disk\u2026',
19803 'Done': 'Analysis complete \u2014 loading your results\u2026',
19804 'Failed': 'Analysis encountered an error. Check the path and permissions, then try again.'
19805 };
19806 var PHASE_STEP = {'Starting':1,'Scanning files':1,'Running':2,'Writing reports':3,'Done':4};
19807 function lcSetPhase(txt) {
19808 var el = document.getElementById("lc-phase"); if (el) el.textContent = txt;
19809 var desc = document.getElementById("lc-stage-desc");
19810 if (desc) desc.textContent = PHASE_DESC[txt] || (txt + '\u2026');
19811 var step = PHASE_STEP[txt] || 1;
19812 for (var i=1;i<=4;i++){var s=document.getElementById("lc-step-"+i);if(!s)continue;s.classList.remove("active","done");if(i<step)s.classList.add("done");else if(i===step)s.classList.add("active");}
19813 }
19814
19815 function lcShowCancelled() {
19816 clearInterval(elapsedTimer);
19817 var ccard = document.getElementById("loading-card"); if (ccard) ccard.classList.remove("lc-pulsing");
19818 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
19819 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
19820 var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
19821 var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
19822 var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
19823 var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
19824 var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
19825 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19826 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19827 }
19828
19829 var lcCancelBtn = document.getElementById("lc-cancel-btn");
19830 if (lcCancelBtn) {
19831 lcCancelBtn.onclick = function() {
19832 if (!activeWaitId) { dismissAnalysisModal(); return; }
19833 lcCancelBtn.disabled = true;
19834 lcCancelBtn.textContent = "Cancelling\u2026";
19835 fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
19836 .then(function() { lcShowCancelled(); })
19837 .catch(function() { lcShowCancelled(); });
19838 };
19839 }
19840
19841 function lcShowError(msg) {
19842 clearInterval(elapsedTimer);
19843 var ecard = document.getElementById("loading-card"); if (ecard) ecard.classList.remove("lc-pulsing");
19844 lcSetPhase("Failed");
19845 var msgEl = document.getElementById("lc-err-msg");
19846 if (msgEl) msgEl.textContent = msg || "Analysis failed.";
19847 var errEl = document.getElementById("lc-err");
19848 var actEl = document.getElementById("lc-actions");
19849 if (errEl) errEl.classList.remove("hidden");
19850 if (actEl) actEl.classList.remove("hidden");
19851 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19852 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19853 }
19854
19855 function lcPoll(waitId) {
19856 fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
19857 .then(function(r) {
19858 if (!r.ok) throw new Error("HTTP " + r.status);
19859 return r.json();
19860 })
19861 .then(function(data) {
19862 pollRetries = 0;
19863 if (data.state === "complete") {
19864 clearInterval(elapsedTimer);
19865 lcSetPhase("Done");
19866 window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
19867 } else if (data.state === "failed") {
19868 lcShowError(data.message);
19869 } else if (data.state === "cancelled") {
19870 lcShowCancelled();
19871 } else {
19872 var s = Math.floor((Date.now() - startTime) / 1000);
19873 if (s > 90 && !warnShown) {
19874 warnShown = true;
19875 var w = document.getElementById("lc-warn");
19876 if (w) w.classList.remove("hidden");
19877 }
19878 lcSetPhase(data.phase || "Running");
19879 var fd = data.files_done || 0, ft = data.files_total || 0;
19880 if (ft > 0) {
19881 var card = document.getElementById("lc-files-card");
19882 if (card) card.classList.remove("hidden");
19883 var el = document.getElementById("lc-files");
19884 if (el) el.textContent = fmt(fd) + " / " + fmt(ft);
19885 var now = Date.now();
19886 var fdelta = fd - lastFd, tdelta = (now - lastFdTime) / 1000;
19887 if (fdelta > 0 && tdelta > 0.4) {
19888 var fps = Math.round(fdelta / tdelta);
19889 var spEl = document.getElementById("lc-speed"); if (spEl) spEl.textContent = fmt(fps);
19890 var spCard = document.getElementById("lc-speed-card"); if (spCard) spCard.classList.remove("hidden");
19891 }
19892 lastFd = fd; lastFdTime = now;
19893 }
19894 setTimeout(function() { lcPoll(waitId); }, 1500);
19895 }
19896 })
19897 .catch(function() {
19898 pollRetries++;
19899 if (pollRetries >= 5) {
19900 lcShowError("Lost connection to server. Reload to check status.");
19901 } else {
19902 setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
19903 }
19904 });
19905 }
19906
19907 var params = new URLSearchParams(formData);
19908 fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
19909 .then(function(r) {
19910 var waitId = r.headers.get("x-wait-id");
19911 if (!waitId) { window.location.href = "/scan"; return; }
19912 activeWaitId = waitId;
19913 setTimeout(function() { lcPoll(waitId); }, 1500);
19914 })
19915 .catch(function(err) {
19916 lcShowError("Could not reach server: " + (err.message || err));
19917 });
19918 }
19919
19920 if (quickScanBtn) {
19921 quickScanBtn.addEventListener("click", function () {
19922 var pathVal = pathInput ? pathInput.value.trim() : "";
19923 if (!pathVal) {
19924 alert("Please enter or browse to a project path first.");
19925 return;
19926 }
19927 quickScanBtn.disabled = true;
19928 quickScanBtn.textContent = "Scanning...";
19929 if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
19930 startAsyncAnalysis(new FormData(form));
19931 });
19932 }
19933
19934 var mixedPolicyInfo = {
19935 code_only: {
19936 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.",
19937 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'
19938 },
19939 code_and_comment: {
19940 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.",
19941 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'
19942 },
19943 comment_only: {
19944 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.",
19945 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'
19946 },
19947 separate_mixed_category: {
19948 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.",
19949 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'
19950 }
19951 };
19952
19953 var scanPresetInfo = {
19954 balanced: {
19955 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.",
19956 chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
19957 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
19958 note: "Best when you want a stable local overview before making deeper adjustments.",
19959 apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
19960 },
19961 code_focused: {
19962 description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
19963 chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
19964 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
19965 note: "Use this when you mainly care about implementation size and want cleaner code totals.",
19966 apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
19967 },
19968 comment_audit: {
19969 description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
19970 chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
19971 example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
19972 note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
19973 apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
19974 },
19975 deep_review: {
19976 description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
19977 chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
19978 example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
19979 note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
19980 apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
19981 }
19982 };
19983
19984 var artifactPresetInfo = {
19985 review: {
19986 description: "HTML report for in-browser review. No PDF or data exports \u2014 fast and lightweight.",
19987 chips: ["HTML", "no PDF", "no JSON/CSV/XLSX"],
19988 example: "Ideal for a quick local review before sharing results."
19989 },
19990 full: {
19991 description: "All artifacts: HTML, PDF, JSON, CSV, and XLSX. Best for handoff packages or archiving.",
19992 chips: ["HTML", "PDF", "JSON", "CSV", "XLSX"],
19993 example: "Use when producing a deliverable or storing a snapshot for future comparison."
19994 },
19995 html_only: {
19996 description: "Standalone HTML report only. No PDF generation, no data files.",
19997 chips: ["HTML only"],
19998 example: "Fastest option when you only need to open the report in a browser."
19999 },
20000 machine: {
20001 description: "JSON and CSV data files only \u2014 no HTML or PDF. Designed for CI pipelines and automation.",
20002 chips: ["JSON", "CSV", "no HTML", "no PDF"],
20003 example: "Use in CI to capture metrics without generating visual reports."
20004 }
20005 };
20006
20007 function applyArtifactPreset() {
20008 var info = artifactPresetInfo[artifactPreset ? artifactPreset.value : "review"];
20009 if (!info) return;
20010 var descEl = document.getElementById("artifact-preset-description");
20011 var exampleEl = document.getElementById("artifact-preset-example");
20012 if (descEl) descEl.textContent = info.description;
20013 if (exampleEl) exampleEl.textContent = info.example;
20014 renderPresetChips("artifact-preset-summary", info.chips);
20015 }
20016
20017 function applyTheme(theme) {
20018 if (theme === "dark") document.body.classList.add("dark-theme");
20019 else document.body.classList.remove("dark-theme");
20020 }
20021
20022 function loadSavedTheme() {
20023 var saved = null;
20024 try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
20025 applyTheme(saved === "dark" ? "dark" : "light");
20026 }
20027
20028 function updateScrollProgress() {
20029 // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
20030 // Within each step, scroll position nudges the bar forward (max just below the next milestone).
20031 var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1–4 (index = step number)
20032 var stepEnd = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
20033 var step = Math.min(Math.max(currentStep, 1), 4);
20034 var base = stepBase[step];
20035 var end = stepEnd[step];
20036
20037 var scrollFrac = 0;
20038 var activePanel = document.querySelector(".wizard-step.active");
20039 if (activePanel) {
20040 var scrollTop = window.scrollY || window.pageYOffset || 0;
20041 var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
20042 var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
20043 var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
20044 var scrolled = scrollTop + viewH - panelTop;
20045 scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
20046 }
20047
20048 var percent = Math.round(base + (end - base) * scrollFrac);
20049 percent = Math.min(end, Math.max(base, percent));
20050 if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
20051 if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
20052 }
20053
20054 function updateWizardProgress() {
20055 updateScrollProgress();
20056 }
20057
20058 var stepDescriptions = [
20059 "Choose a project folder, apply scope filters, and preview which files will be counted.",
20060 "Configure how mixed code-plus-comment lines and docstrings are classified.",
20061 "Pick your output formats, scan preset, and where reports are saved.",
20062 "Review all settings and launch the analysis."
20063 ];
20064
20065 function updateStepNav(step) {
20066 var infoLabel = document.getElementById("step-nav-info-label");
20067 var infoDesc = document.getElementById("step-nav-info-desc");
20068 if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
20069 if (infoDesc) infoDesc.textContent = stepDescriptions[step - 1] || "";
20070 }
20071
20072 function updateSidebarSummary() {
20073 var sumPath = document.getElementById("sum-path");
20074 var sumPreset = document.getElementById("sum-preset");
20075 var sumOutput = document.getElementById("sum-output");
20076 var sidebarSummary = document.getElementById("sidebar-summary");
20077 var pathVal = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
20078 var presetVal = (scanPreset && scanPreset.value) ? scanPreset.value.replace(/_/g, " ") : "";
20079 var outputVal = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
20080 if (sumPath) sumPath.textContent = pathVal || "\u2014";
20081 if (sumPreset) sumPreset.textContent = presetVal || "\u2014";
20082 if (sumOutput) sumOutput.textContent = outputVal || "\u2014";
20083 if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
20084 }
20085
20086 function setStep(step, pushHistory) {
20087 currentStep = step;
20088 stepPanels.forEach(function (panel) {
20089 panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
20090 });
20091 stepButtons.forEach(function (button) {
20092 button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
20093 });
20094 var layoutEl = document.querySelector(".layout");
20095 if (layoutEl) layoutEl.setAttribute("data-active-step", step);
20096 updateWizardProgress();
20097 updateStepNav(step);
20098 stepButtons.forEach(function(btn) {
20099 var t = Number(btn.getAttribute("data-step-target"));
20100 btn.classList.toggle("done", t < step);
20101 });
20102 updateSidebarSummary();
20103
20104 if (pushHistory !== false) {
20105 try {
20106 history.pushState({ wizardStep: step }, "", "#step" + step);
20107 } catch (e) {}
20108 }
20109
20110 window.scrollTo({ top: 0, behavior: "instant" });
20111 }
20112
20113 window.addEventListener("popstate", function (e) {
20114 if (e.state && e.state.wizardStep) {
20115 setStep(e.state.wizardStep, false);
20116 } else {
20117 var hashMatch = location.hash.match(/^#step([1-4])$/);
20118 if (hashMatch) setStep(Number(hashMatch[1]), false);
20119 }
20120 });
20121
20122 function inferTitleFromPath(value) {
20123 if (!value) return "project";
20124 var cleaned = value.replace(/[\/\\]+$/, "");
20125 var parts = cleaned.split(/[\/\\]/).filter(Boolean);
20126 return parts.length ? parts[parts.length - 1] : value;
20127 }
20128
20129 function updateReportTitleFromPath() {
20130 var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
20131 if (!reportTitleTouched) {
20132 reportTitleInput.value = inferred;
20133 }
20134 var title = reportTitleInput.value || inferred;
20135 if (liveReportTitle) liveReportTitle.textContent = title;
20136 if (reportTitlePreview) reportTitlePreview.textContent = title;
20137 document.title = "OxideSLOC | " + title;
20138
20139 var projectPath = (pathInput.value || "").trim();
20140 if (navProjectPill && navProjectTitle) {
20141 if (projectPath.length > 0) {
20142 navProjectTitle.textContent = inferred;
20143 navProjectPill.classList.add("visible");
20144 } else {
20145 navProjectTitle.textContent = "";
20146 navProjectPill.classList.remove("visible");
20147 }
20148 }
20149 }
20150
20151 function updateMixedPolicyUI() {
20152 var key = mixedLinePolicy.value || "code_only";
20153 var info = mixedPolicyInfo[key];
20154 document.getElementById("mixed-policy-description").textContent = info.description;
20155 document.getElementById("mixed-policy-example").textContent = info.example;
20156 }
20157
20158 function updatePythonDocstringUI() {
20159 var checked = !!pythonDocstrings.checked;
20160 document.getElementById("python-docstring-example").textContent = checked
20161 ? 'def greet():\n """Greet the user.""" \u2190 comment\n print("hi")'
20162 : 'def greet():\n """Greet the user.""" \u2190 not counted\n print("hi")';
20163 document.getElementById("python-docstring-live-help").textContent = checked
20164 ? "Enabled: docstrings contribute to comment-style totals."
20165 : "Disabled: docstrings are not counted as comment content.";
20166 }
20167
20168 function renderPresetChips(targetId, chips) {
20169 var target = document.getElementById(targetId);
20170 if (!target) return;
20171 target.innerHTML = (chips || []).map(function (chip) {
20172 return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
20173 }).join('');
20174 }
20175
20176 function updatePresetDescriptions() {
20177 var scanInfo = scanPresetInfo[scanPreset.value];
20178 if (!scanInfo) return;
20179 document.getElementById("scan-preset-description").textContent = scanInfo.description;
20180 document.getElementById("scan-preset-example").textContent = scanInfo.example;
20181 document.getElementById("scan-preset-note").textContent = scanInfo.note;
20182 renderPresetChips("scan-preset-summary", scanInfo.chips);
20183 }
20184
20185 function applyScanPreset() {
20186 var info = scanPresetInfo[scanPreset.value];
20187 if (!info || !info.apply) return;
20188 mixedLinePolicy.value = info.apply.mixed;
20189 pythonDocstrings.checked = !!info.apply.docstrings;
20190 document.getElementById("generated_file_detection").value = info.apply.generated;
20191 document.getElementById("minified_file_detection").value = info.apply.minified;
20192 document.getElementById("vendor_directory_detection").value = info.apply.vendor;
20193 document.getElementById("include_lockfiles").value = info.apply.lockfiles;
20194 document.getElementById("binary_file_behavior").value = info.apply.binary;
20195 updateMixedPolicyUI();
20196 updatePythonDocstringUI();
20197 }
20198
20199 function updateReview() {
20200 var scanSummary = document.getElementById("review-scan-summary");
20201 var countSummary = document.getElementById("review-count-summary");
20202 var artifactSummary = document.getElementById("review-artifact-summary");
20203 var outputSummary = document.getElementById("review-output-summary");
20204 var previewSummary = document.getElementById("review-preview-summary");
20205 var readinessSummary = document.getElementById("review-readiness-summary");
20206 var includeText = document.getElementById("include_globs").value.trim();
20207 var excludeText = document.getElementById("exclude_globs").value.trim();
20208 var sidePathPreview = document.getElementById("side-path-preview");
20209 var sideOutputPreview = document.getElementById("side-output-preview");
20210 var sideTitlePreview = document.getElementById("side-title-preview");
20211
20212 if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
20213 if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
20214 if (sideTitlePreview) {
20215 var rt = document.getElementById("report_title");
20216 sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
20217 }
20218
20219 scanSummary.innerHTML = ""
20220 + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
20221 + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
20222 + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
20223
20224 countSummary.innerHTML = ""
20225 + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
20226 + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
20227 + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
20228 + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
20229 + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
20230 + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
20231 + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
20232 + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
20233
20234 artifactSummary.innerHTML = "<li>HTML, PDF, JSON, CSV, XLSX (always generated)</li>";
20235
20236 outputSummary.innerHTML = ""
20237 + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
20238 + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
20239
20240 if (previewSummary) {
20241 if (GIT_MODE) {
20242 previewSummary.innerHTML = '<li style="color:var(--muted-text,#888);font-style:italic;">Scope preview is not pre-computed in git-browser mode \u2014 the repository will be cloned and fully analyzed during the scan run.</li>';
20243 } else {
20244 var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
20245 var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
20246 var statMap = {};
20247 statButtons.forEach(function (button) {
20248 var valueNode = button.querySelector('.scope-stat-value');
20249 statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
20250 });
20251 previewSummary.innerHTML = ''
20252 + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
20253 + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
20254 + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
20255 + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
20256 + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
20257 + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
20258
20259 if (readinessSummary) {
20260 readinessSummary.innerHTML = ''
20261 + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
20262 + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
20263 + '<li>Ready to run: ' + (pathInput.value ? 'yes' : 'no') + '</li>';
20264 }
20265 } // end else (non-GIT_MODE)
20266 }
20267 }
20268
20269 function escapeHtml(value) {
20270 return String(value)
20271 .replace(/&/g, "&")
20272 .replace(/</g, "<")
20273 .replace(/>/g, ">")
20274 .replace(/"/g, """)
20275 .replace(/'/g, "'");
20276 }
20277
20278 function isPythonVisible() {
20279 return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
20280 }
20281
20282 function syncPythonVisibility() {
20283 var html = previewPanel.textContent || "";
20284 var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
20285 pythonWraps.forEach(function (node) {
20286 node.classList.toggle("hidden", !hasPython);
20287 });
20288 }
20289
20290 function attachPreviewInteractions() {
20291 // Multiple-repository caution banner: gate step 1 until acknowledged, and
20292 // let each listed repo be picked as the scan root with one click.
20293 var multiRepoBanner = previewPanel.querySelector(".preview-warning[data-multi-repo]");
20294 if (multiRepoBanner) {
20295 multiRepoBlocked = true;
20296 refreshStep1Gate();
20297 var ackBox = multiRepoBanner.querySelector(".multi-repo-ack");
20298 if (ackBox) {
20299 ackBox.addEventListener("change", function () {
20300 multiRepoBlocked = !ackBox.checked;
20301 refreshStep1Gate();
20302 });
20303 }
20304 var repoButtons = Array.prototype.slice.call(multiRepoBanner.querySelectorAll(".repo-pick"));
20305 repoButtons.forEach(function (btn) {
20306 btn.addEventListener("click", function () {
20307 var repoPath = btn.getAttribute("data-repo-path") || "";
20308 if (!repoPath || !pathInput) return;
20309 pathInput.value = repoPath;
20310 scrollInputToEnd(pathInput);
20311 updateReportTitleFromPath();
20312 autoSetOutputDir(repoPath);
20313 fetchProjectHistory(repoPath);
20314 loadPreview();
20315 updateReview();
20316 });
20317 });
20318 }
20319 var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
20320 var treeContainer = previewPanel.querySelector(".file-explorer-tree");
20321 var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
20322 var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
20323 var filterSelect = previewPanel.querySelector("#explorer-filter-select");
20324 var searchInput = previewPanel.querySelector("#explorer-search");
20325 var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
20326 var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
20327 var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
20328 var activeFilter = "all";
20329 var activeLanguage = "";
20330 var searchTerm = "";
20331 var currentSortKey = null;
20332 var currentSortOrder = "asc";
20333 var childRows = {};
20334
20335 rows.forEach(function (row) {
20336 var parentId = row.getAttribute("data-parent-id") || "";
20337 var rowId = row.getAttribute("data-row-id") || "";
20338 if (!childRows[parentId]) childRows[parentId] = [];
20339 childRows[parentId].push(rowId);
20340 });
20341
20342 function rowById(id) {
20343 return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
20344 }
20345
20346 function hasCollapsedAncestor(row) {
20347 var parentId = row.getAttribute("data-parent-id");
20348 while (parentId) {
20349 var parent = rowById(parentId);
20350 if (!parent) break;
20351 if (parent.getAttribute("data-expanded") === "false") return true;
20352 parentId = parent.getAttribute("data-parent-id");
20353 }
20354 return false;
20355 }
20356
20357 function updateToggleGlyph(row) {
20358 var toggle = row.querySelector(".tree-toggle");
20359 if (!toggle) return;
20360 toggle.textContent = row.getAttribute("data-expanded") === "false" ? "\u25b8" : "\u25be";
20361 }
20362
20363 function rowSortValue(row, key) {
20364 return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
20365 }
20366
20367 function updateSortButtons() {
20368 sortButtons.forEach(function (button) {
20369 var isActive = button.getAttribute("data-sort-key") === currentSortKey;
20370 var indicator = button.querySelector(".tree-sort-indicator");
20371 button.classList.toggle("active", isActive);
20372 button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
20373 if (indicator) {
20374 indicator.textContent = !isActive ? "\u2195" : (currentSortOrder === "asc" ? "\u2191" : "\u2193");
20375 }
20376 });
20377 }
20378
20379 function sortSiblingRows() {
20380 if (!treeContainer) {
20381 updateSortButtons();
20382 return;
20383 }
20384
20385 var rowMap = {};
20386 var childrenMap = {};
20387 rows.forEach(function (row) {
20388 var rowId = row.getAttribute("data-row-id");
20389 var parentId = row.getAttribute("data-parent-id") || "";
20390 rowMap[rowId] = row;
20391 if (!childrenMap[parentId]) childrenMap[parentId] = [];
20392 childrenMap[parentId].push(rowId);
20393 });
20394
20395 Object.keys(childrenMap).forEach(function (parentId) {
20396 if (!parentId) return;
20397 childrenMap[parentId].sort(function (a, b) {
20398 var rowA = rowMap[a];
20399 var rowB = rowMap[b];
20400 if (!currentSortKey) {
20401 return Number(a) - Number(b);
20402 }
20403 var valueA = rowSortValue(rowA, currentSortKey);
20404 var valueB = rowSortValue(rowB, currentSortKey);
20405 if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
20406 if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
20407 var fallbackA = rowSortValue(rowA, "name");
20408 var fallbackB = rowSortValue(rowB, "name");
20409 if (fallbackA < fallbackB) return -1;
20410 if (fallbackA > fallbackB) return 1;
20411 return Number(a) - Number(b);
20412 });
20413 });
20414
20415 var orderedIds = [];
20416 function pushChildren(parentId) {
20417 (childrenMap[parentId] || []).forEach(function (childId) {
20418 orderedIds.push(childId);
20419 pushChildren(childId);
20420 });
20421 }
20422
20423 (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
20424 orderedIds.push(topId);
20425 pushChildren(topId);
20426 });
20427
20428 orderedIds.forEach(function (id) {
20429 if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
20430 });
20431 updateSortButtons();
20432 }
20433
20434 function updateLanguageButtons() {
20435 languageButtons.forEach(function (button) {
20436 var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
20437 var isActive = languageValue === activeLanguage;
20438 button.classList.toggle("active", isActive);
20439 });
20440 }
20441
20442 function rowSelfMatches(row) {
20443 var kind = row.getAttribute("data-kind");
20444 var status = row.getAttribute("data-status");
20445 var language = (row.getAttribute("data-language") || "").toLowerCase();
20446 var name = row.getAttribute("data-name-lower") || "";
20447 var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
20448 var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
20449 var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
20450 var passesLanguage = !activeLanguage || language === activeLanguage;
20451 return passesFilter && passesSearch && passesLanguage;
20452 }
20453
20454 function hasMatchingDescendant(rowId) {
20455 return (childRows[rowId] || []).some(function (childId) {
20456 var childRow = rowById(childId);
20457 return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
20458 });
20459 }
20460
20461 function rowMatches(row) {
20462 if (rowSelfMatches(row)) return true;
20463 return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
20464 }
20465
20466 function resetViewState() {
20467 activeFilter = "all";
20468 activeLanguage = "";
20469 searchTerm = "";
20470 currentSortKey = null;
20471 currentSortOrder = "asc";
20472 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
20473 if (searchInput) searchInput.value = "";
20474 if (filterSelect) filterSelect.value = "all";
20475 updateLanguageButtons();
20476 }
20477
20478 function applyVisibility() {
20479 rows.forEach(function (row) {
20480 var visible = rowMatches(row) && !hasCollapsedAncestor(row);
20481 row.classList.toggle("hidden-by-filter", !visible);
20482 row.style.display = visible ? "grid" : "none";
20483 });
20484 buttons.forEach(function (button) {
20485 button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
20486 });
20487 if (filterSelect) filterSelect.value = activeFilter;
20488 }
20489
20490 var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
20491 var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
20492 var originalStats = {};
20493 buttons.forEach(function (btn) {
20494 var f = btn.getAttribute('data-filter');
20495 var v = btn.querySelector('.scope-stat-value');
20496 if (f && v) originalStats[f] = v.textContent;
20497 });
20498
20499 function applySubmoduleStats(statsJson) {
20500 try {
20501 var s = JSON.parse(statsJson);
20502 buttons.forEach(function (btn) {
20503 var f = btn.getAttribute('data-filter');
20504 var v = btn.querySelector('.scope-stat-value');
20505 if (!v) return;
20506 if (f === 'dir') v.textContent = s.dirs;
20507 else if (f === 'file') v.textContent = s.files;
20508 else if (f === 'supported') v.textContent = s.supported;
20509 else if (f === 'skipped') v.textContent = s.skipped;
20510 else if (f === 'unsupported') v.textContent = s.unsupported;
20511 });
20512 } catch (e) {}
20513 }
20514
20515 function restoreBaseRepoStats() {
20516 buttons.forEach(function (btn) {
20517 var f = btn.getAttribute('data-filter');
20518 var v = btn.querySelector('.scope-stat-value');
20519 if (v && originalStats[f]) v.textContent = originalStats[f];
20520 });
20521 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
20522 if (baseRepoBtn) baseRepoBtn.style.display = 'none';
20523 }
20524
20525 submoduleChips.forEach(function (chip) {
20526 chip.addEventListener('click', function () {
20527 var statsJson = chip.getAttribute('data-sub-stats');
20528 if (!statsJson) return;
20529 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
20530 chip.classList.add('active');
20531 applySubmoduleStats(statsJson);
20532 if (baseRepoBtn) baseRepoBtn.style.display = '';
20533 });
20534 });
20535
20536 if (baseRepoBtn) {
20537 baseRepoBtn.addEventListener('click', function () {
20538 restoreBaseRepoStats();
20539 resetViewState();
20540 sortSiblingRows();
20541 applyVisibility();
20542 });
20543 }
20544
20545 buttons.forEach(function (button) {
20546 button.addEventListener("click", function () {
20547 var filterValue = button.getAttribute("data-filter") || "all";
20548 if (filterValue === "reset-view") {
20549 restoreBaseRepoStats();
20550 resetViewState();
20551 sortSiblingRows();
20552 applyVisibility();
20553 return;
20554 }
20555 activeFilter = filterValue;
20556 applyVisibility();
20557 });
20558 });
20559
20560 rows.forEach(function (row) {
20561 updateToggleGlyph(row);
20562 var toggle = row.querySelector(".tree-toggle");
20563 if (toggle) {
20564 toggle.addEventListener("click", function () {
20565 var expanded = row.getAttribute("data-expanded") !== "false";
20566 row.setAttribute("data-expanded", expanded ? "false" : "true");
20567 updateToggleGlyph(row);
20568 applyVisibility();
20569 });
20570 }
20571 });
20572
20573 actionButtons.forEach(function (button) {
20574 button.addEventListener("click", function () {
20575 var action = button.getAttribute("data-explorer-action");
20576 if (action === "expand-all") {
20577 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
20578 } else if (action === "collapse-all") {
20579 dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
20580 } else if (action === "clear-filters") {
20581 resetViewState();
20582 }
20583 sortSiblingRows();
20584 applyVisibility();
20585 });
20586 });
20587
20588 if (filterSelect) {
20589 filterSelect.addEventListener("change", function () {
20590 activeFilter = filterSelect.value || "all";
20591 applyVisibility();
20592 });
20593 }
20594
20595 languageButtons.forEach(function (button) {
20596 button.addEventListener("click", function () {
20597 activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
20598 updateLanguageButtons();
20599 applyVisibility();
20600 });
20601 });
20602
20603 sortButtons.forEach(function (button) {
20604 button.addEventListener("click", function () {
20605 var sortKey = button.getAttribute("data-sort-key");
20606 if (currentSortKey === sortKey) {
20607 currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
20608 } else {
20609 currentSortKey = sortKey;
20610 currentSortOrder = "asc";
20611 }
20612 sortSiblingRows();
20613 applyVisibility();
20614 });
20615 });
20616
20617 if (searchInput) {
20618 searchInput.addEventListener("input", function () {
20619 searchTerm = searchInput.value.trim().toLowerCase();
20620 applyVisibility();
20621 });
20622 }
20623
20624 updateLanguageButtons();
20625 sortSiblingRows();
20626 applyVisibility();
20627 }
20628
20629 function loadPreview() {
20630 if (!previewPanel || !pathInput) return;
20631 // A fresh preview re-establishes the multi-repo gate; clear any prior ack.
20632 multiRepoBlocked = false;
20633 refreshStep1Gate();
20634 if (GIT_MODE) {
20635 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>';
20636 setPreviewLoading(false);
20637 return;
20638 }
20639 var path = pathInput.value.trim();
20640 var zeroWarn = document.getElementById('zero-files-warning');
20641 if (!path) {
20642 previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
20643 if (zeroWarn) zeroWarn.style.display = 'none';
20644 setPreviewLoading(false);
20645 return;
20646 }
20647 var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
20648 var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
20649 if (window._previewInterval) { clearInterval(window._previewInterval); window._previewInterval = null; }
20650 if (window._previewElapsedTimer) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; }
20651 var myGen = ++_previewGen;
20652 var _prevMsgs = [
20653 'Scanning directory structure\u2026',
20654 'Detecting file types\u2026',
20655 'Applying include / exclude filters\u2026',
20656 'Estimating file counts\u2026',
20657 'Building scope preview\u2026',
20658 'Almost there\u2026'
20659 ];
20660 var _prevMsgIdx = 0;
20661 var _prevStart = Date.now();
20662 previewPanel.innerHTML =
20663 '<div class="preview-loading">' +
20664 '<div class="preview-spinner"></div>' +
20665 '<div class="preview-loading-text">' +
20666 '<div class="preview-loading-msg" id="plm">' + _prevMsgs[0] + '</div>' +
20667 '<div class="preview-loading-elapsed" id="ple">0s elapsed</div>' +
20668 '</div></div>';
20669 var _sizeTextEl = document.getElementById('project-size-text');
20670 if (_sizeTextEl) _sizeTextEl.textContent = 'Project size: Detecting\u2026';
20671 window._previewInterval = setInterval(function() {
20672 if (myGen !== _previewGen) { clearInterval(window._previewInterval); window._previewInterval = null; return; }
20673 _prevMsgIdx = (_prevMsgIdx + 1) % _prevMsgs.length;
20674 var ml = document.getElementById('plm');
20675 if (ml) ml.textContent = _prevMsgs[_prevMsgIdx];
20676 }, 1500);
20677 window._previewElapsedTimer = setInterval(function() {
20678 if (myGen !== _previewGen) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; return; }
20679 var el = document.getElementById('ple');
20680 if (el) el.textContent = Math.round((Date.now() - _prevStart) / 1000) + 's elapsed';
20681 }, 1000);
20682 setPreviewLoading(true);
20683 var previewUrl = "/preview?path=" + encodeURIComponent(path)
20684 + "&include_globs=" + encodeURIComponent(includeValue)
20685 + "&exclude_globs=" + encodeURIComponent(excludeValue);
20686 fetch(previewUrl)
20687 .then(function (response) { return response.text(); })
20688 .then(function (html) {
20689 if (myGen !== _previewGen) return;
20690 clearInterval(window._previewInterval); window._previewInterval = null;
20691 clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
20692 setPreviewLoading(false);
20693 previewPanel.innerHTML = html;
20694 attachPreviewInteractions();
20695 syncPythonVisibility();
20696 updateReview();
20697 setTimeout(collapseLanguagePills, 50);
20698 var explorerWrap = previewPanel.querySelector('.explorer-wrap');
20699 var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
20700 var sizeText = document.getElementById('project-size-text');
20701 var sizeBtn = document.getElementById('project-size-btn');
20702 // In server mode with upload sizes available, keep the compressed/original pair.
20703 if (SERVER_MODE && window._lastUploadSizes) {
20704 var us = window._lastUploadSizes;
20705 if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(us.original_bytes) +
20706 ' \xb7 Compressed: ' + fmtBytes(us.compressed_bytes);
20707 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(us.original_bytes) +
20708 ' \u2014 Compressed archive size: ' + fmtBytes(us.compressed_bytes);
20709 } else if (sizeText && projectSize) {
20710 sizeText.textContent = 'Project size: ' + projectSize;
20711 if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
20712 } else if (sizeText) {
20713 sizeText.textContent = 'Project size: \u2014';
20714 }
20715 if (zeroWarn) {
20716 var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
20717 var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
20718 var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
20719 var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
20720 if (supportedCount === 0 && fileCount > 0) {
20721 zeroWarn.textContent = '\u26a0 Warning: No supported source files detected\u2014this scan will analyze 0 files. The directory may contain only binaries, archives, or unsupported file types (e.g. JSON, Markdown).';
20722 zeroWarn.style.display = '';
20723 } else {
20724 zeroWarn.style.display = 'none';
20725 }
20726 }
20727 })
20728 .catch(function (err) {
20729 if (myGen !== _previewGen) return;
20730 clearInterval(window._previewInterval); window._previewInterval = null;
20731 clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
20732 setPreviewLoading(false);
20733 previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
20734 });
20735 }
20736
20737 function pickDirectory(targetInput, kind) {
20738 if (!targetInput) {
20739 showBannerToast("Directory picker: input element not found.", true);
20740 return;
20741 }
20742 if (SERVER_MODE) {
20743 if (kind === 'output') {
20744 showBannerToast(
20745 'Server mode: type the output path directly into the field \u2014 the path must exist on the server, not your local machine.',
20746 false,
20747 { top: true, icon: '\u{1F4C1}' }
20748 );
20749 return;
20750 }
20751 var inputEl = kind === 'coverage'
20752 ? document.getElementById('cov-upload-input')
20753 : document.getElementById('dir-upload-input');
20754 if (!inputEl) return;
20755 inputEl.onchange = function () {
20756 var files = inputEl.files;
20757 if (!files || files.length === 0) return;
20758 var browseBtn = targetInput === pathInput ? browsePath : browseOutputDir;
20759 if (browseBtn) browseBtn.disabled = true;
20760
20761 function fileToBase64(file) {
20762 return new Promise(function (resolve, reject) {
20763 var reader = new FileReader();
20764 reader.onload = function () {
20765 var b64 = reader.result.split(',')[1];
20766 resolve(b64);
20767 };
20768 reader.onerror = reject;
20769 reader.readAsDataURL(file);
20770 });
20771 }
20772
20773 if (kind === 'coverage') {
20774 var f = files[0];
20775 if (previewPanel && targetInput === pathInput)
20776 previewPanel.innerHTML = '<div class="preview-error">Uploading coverage file\u2026</div>';
20777 fileToBase64(f).then(function (b64) {
20778 return fetch('/api/upload-file', {
20779 method: 'POST',
20780 headers: { 'Content-Type': 'application/json' },
20781 body: JSON.stringify({ filename: f.name, content: b64 })
20782 }).then(function (r) { return r.json(); });
20783 })
20784 .then(function (d) {
20785 if (d && d.tmp_path) {
20786 if (coverageInput) coverageInput.value = d.tmp_path;
20787 setCovStatus('idle');
20788 } else if (d && d.error) { showBannerToast(d.error, true); }
20789 })
20790 .catch(function (e) { showBannerToast('Upload failed: ' + String(e), true); })
20791 .finally(function () { if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; });
20792 } else {
20793 // ── Filter to source-code files only ─────────────────────────
20794 // Binary, generated, and dependency files (node_modules, .git,
20795 // build artifacts) are skipped so they are never uploaded.
20796 var CODE_EXTS = new Set([
20797 'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
20798 'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
20799 'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
20800 'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
20801 'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
20802 'asm','s','S','objc','lisp','el','rkt','ml','mli','ocaml','v','sv','vhd','vhdl',
20803 'tf','hcl','proto','thrift','avsc','graphql','gql'
20804 ]);
20805 var codeFiles = [];
20806 for (var i = 0; i < files.length; i++) {
20807 var f = files[i];
20808 var name = f.name;
20809 if (name === 'Makefile' || name === 'Dockerfile' || name === 'Gemfile' ||
20810 name === 'Rakefile' || name === 'Procfile' || name === 'Justfile') {
20811 codeFiles.push(f); continue;
20812 }
20813 var dot = name.lastIndexOf('.');
20814 if (dot >= 0 && CODE_EXTS.has(name.slice(dot + 1).toLowerCase())) codeFiles.push(f);
20815 }
20816 // Collect specific .git metadata files for server-side git detection.
20817 // These have no source extension so they are excluded by the loop above,
20818 // but the server needs them to read branch/commit/author without running git.
20819 var gitMetaFiles = [];
20820 for (var i = 0; i < files.length; i++) {
20821 var f = files[i];
20822 var rp = (f.webkitRelativePath || '').replace(/\\/g, '/');
20823 var gitIdx = rp.indexOf('/.git/');
20824 if (gitIdx < 0) continue;
20825 var gitRel = rp.slice(gitIdx + 1);
20826 if (gitRel === '.git/HEAD' || gitRel === '.git/packed-refs' ||
20827 gitRel === '.git/logs/HEAD' ||
20828 gitRel.startsWith('.git/refs/heads/') ||
20829 gitRel.startsWith('.git/refs/tags/')) {
20830 gitMetaFiles.push(f);
20831 }
20832 }
20833 var uploadFiles = codeFiles.concat(gitMetaFiles);
20834 var total = files.length;
20835 var kept = codeFiles.length;
20836 if (kept === 0) {
20837 if (previewPanel && targetInput === pathInput)
20838 previewPanel.innerHTML = '<div class="preview-error">No supported source files found in the selected folder (' + total.toLocaleString() + ' files scanned).</div>';
20839 if (browseBtn) browseBtn.disabled = false;
20840 inputEl.value = '';
20841 return;
20842 }
20843
20844 // ── Helper: apply upload result to UI ────────────────────────
20845 // sizes = {compressed_bytes, original_bytes} from the server response (server mode only).
20846 function applyUploadResult(tmpPath, sizes) {
20847 targetInput.value = tmpPath;
20848 scrollInputToEnd(targetInput);
20849 if (sizes && SERVER_MODE) {
20850 window._lastUploadSizes = sizes;
20851 // Immediately show both sizes before preview loads.
20852 var sizeText = document.getElementById('project-size-text');
20853 var sizeBtn = document.getElementById('project-size-btn');
20854 if (sizeText) {
20855 sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
20856 ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
20857 }
20858 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
20859 ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
20860 }
20861 if (targetInput === pathInput) {
20862 updateReportTitleFromPath();
20863 autoSetOutputDir(tmpPath);
20864 fetchProjectHistory(tmpPath);
20865 loadPreview();
20866 suggestCoverageFile(tmpPath);
20867 }
20868 updateReview();
20869 if (browseBtn) browseBtn.disabled = false;
20870 inputEl.value = '';
20871 }
20872
20873 // ── Path A: tar.gz via native CompressionStream (Chrome 80+, FF 113+, Safari 16.4+)
20874 if (typeof CompressionStream !== 'undefined') {
20875 if (previewPanel && targetInput === pathInput)
20876 previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
20877
20878 // Build a minimal POSIX ustar tar header for a single file entry.
20879 function buildUstarHeader(filePath, fileSize) {
20880 var BLOCK = 512;
20881 var hdr = new Uint8Array(BLOCK);
20882 var enc = new TextEncoder();
20883 function wStr(off, len, s) {
20884 var b = enc.encode(s);
20885 for (var i = 0; i < Math.min(b.length, len); i++) hdr[off + i] = b[i];
20886 }
20887 function wOct(off, len, val) {
20888 var s = val.toString(8);
20889 while (s.length < len - 1) s = '0' + s;
20890 wStr(off, len, s + '\0');
20891 }
20892 // Long-path split: ustar name ≤99 chars, prefix ≤154 chars.
20893 var name = filePath, prefix = '';
20894 if (filePath.length > 99) {
20895 var split = filePath.lastIndexOf('/', 154);
20896 if (split > 0 && filePath.length - split - 1 <= 99) {
20897 prefix = filePath.substring(0, split);
20898 name = filePath.substring(split + 1);
20899 } else { name = filePath.substring(0, 99); }
20900 }
20901 wStr(0, 100, name); // name
20902 wOct(100, 8, 0o000644); // mode
20903 wOct(108, 8, 0); // uid
20904 wOct(116, 8, 0); // gid
20905 wOct(124, 12, fileSize); // size
20906 wOct(136, 12, 0); // mtime (epoch)
20907 for (var i = 148; i < 156; i++) hdr[i] = 32; // checksum placeholder = spaces
20908 hdr[156] = 48; // type flag '0' = regular file
20909 wStr(157, 100, ''); // linkname
20910 wStr(257, 6, 'ustar'); // magic
20911 wStr(263, 2, '00'); // version
20912 wStr(265, 32, ''); // uname
20913 wStr(297, 32, ''); // gname
20914 wOct(329, 8, 0); // devmajor
20915 wOct(337, 8, 0); // devminor
20916 wStr(345, 155, prefix); // prefix
20917 // Compute checksum (sum of all bytes, placeholder = 32).
20918 var chk = 0;
20919 for (var i = 0; i < BLOCK; i++) chk += hdr[i];
20920 var cs = chk.toString(8);
20921 while (cs.length < 6) cs = '0' + cs;
20922 wStr(148, 8, cs + '\0 ');
20923 return hdr;
20924 }
20925
20926 // Build tar.gz one file at a time, piping through CompressionStream.
20927 // RAM usage = compressed output buffer + one file at a time.
20928 (async function () {
20929 try {
20930 var BLOCK = 512;
20931 var cs = new CompressionStream('gzip');
20932 var writer = cs.writable.getWriter();
20933 var chunks = [];
20934 var reader = cs.readable.getReader();
20935 var collecting = (async function () {
20936 while (true) { var r = await reader.read(); if (r.done) break; chunks.push(r.value); }
20937 })();
20938
20939 for (var i = 0; i < uploadFiles.length; i++) {
20940 var file = uploadFiles[i];
20941 var path = file.webkitRelativePath || file.name;
20942 var buf = await file.arrayBuffer();
20943 var data = new Uint8Array(buf);
20944 // Header block
20945 await writer.write(buildUstarHeader(path, data.length));
20946 // Data padded to 512-byte boundary
20947 if (data.length > 0) {
20948 var padded = Math.ceil(data.length / BLOCK) * BLOCK;
20949 var block = new Uint8Array(padded);
20950 block.set(data);
20951 await writer.write(block);
20952 }
20953 if ((i + 1) % 50 === 0 || i === uploadFiles.length - 1) {
20954 if (previewPanel && targetInput === pathInput)
20955 previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i + 1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
20956 }
20957 }
20958 // End-of-archive: two 512-byte zero blocks
20959 await writer.write(new Uint8Array(BLOCK * 2));
20960 await writer.close();
20961 await collecting;
20962
20963 var blob = new Blob(chunks, { type: 'application/gzip' });
20964 var sizeMB = (blob.size / 1048576).toFixed(1);
20965 if (previewPanel && targetInput === pathInput)
20966 previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + (total !== kept ? kept.toLocaleString() + ' of ' + total.toLocaleString() + ' files' : kept.toLocaleString() + ' files') + ')\u2026</div>';
20967
20968 var resp = await fetch('/api/upload-tarball', {
20969 method: 'POST',
20970 headers: { 'Content-Type': 'application/gzip' },
20971 body: blob
20972 });
20973 var d = await resp.json();
20974 if (d && d.tmp_path) {
20975 applyUploadResult(d.tmp_path, {
20976 compressed_bytes: d.compressed_bytes || 0,
20977 original_bytes: d.original_bytes || 0
20978 });
20979 } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
20980 } catch (e) {
20981 showBannerToast('Upload failed: ' + String(e), true);
20982 if (browseBtn) browseBtn.disabled = false;
20983 inputEl.value = '';
20984 }
20985 })();
20986
20987 } else {
20988 // ── Path B: Legacy fallback — sequential JSON+base64 batches ─
20989 // Used only on browsers that lack CompressionStream (pre-2023).
20990 var BATCH = 200;
20991 var batches = [];
20992 for (var b = 0; b < uploadFiles.length; b += BATCH) batches.push(uploadFiles.slice(b, b + BATCH));
20993 var totalBatches = batches.length;
20994 if (previewPanel && targetInput === pathInput)
20995 previewPanel.innerHTML = '<div class="preview-error">Uploading ' + kept.toLocaleString() + ' code file' + (kept === 1 ? '' : 's') + (total !== kept ? ' of ' + total.toLocaleString() + ' total' : '') + '\u2026</div>';
20996
20997 function sendBatch(idx, currentUploadId, lastTmpPath) {
20998 if (idx >= totalBatches) { applyUploadResult(lastTmpPath); return; }
20999 if (previewPanel && targetInput === pathInput && totalBatches > 1)
21000 previewPanel.innerHTML = '<div class="preview-error">Uploading batch ' + (idx + 1) + ' of ' + totalBatches + '\u2026</div>';
21001 Promise.all(batches[idx].map(function (file) {
21002 return fileToBase64(file).then(function (b64) {
21003 return { path: file.webkitRelativePath || file.name, content: b64 };
21004 });
21005 })).then(function (fileList) {
21006 var body = { files: fileList };
21007 if (currentUploadId) body.upload_id = currentUploadId;
21008 return fetch('/api/upload-directory', {
21009 method: 'POST', headers: { 'Content-Type': 'application/json' },
21010 body: JSON.stringify(body)
21011 }).then(function (r) { return r.json(); });
21012 }).then(function (d) {
21013 if (d && d.tmp_path) sendBatch(idx + 1, d.upload_id || currentUploadId, d.tmp_path);
21014 else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
21015 }).catch(function (e) {
21016 showBannerToast('Upload failed: ' + String(e), true);
21017 if (browseBtn) browseBtn.disabled = false; inputEl.value = '';
21018 });
21019 }
21020 sendBatch(0, null, '');
21021 }
21022 }
21023 };
21024 inputEl.click();
21025 return;
21026 }
21027
21028 var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
21029 if (browseButton) browseButton.disabled = true;
21030
21031 if (previewPanel && targetInput === pathInput) {
21032 previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
21033 }
21034
21035 fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "¤t=" + encodeURIComponent(targetInput.value || ""))
21036 .then(function (response) { return response.ok ? response.json() : { cancelled: true }; })
21037 .then(function (data) {
21038 if (data && data.selected_path) {
21039 targetInput.value = data.selected_path;
21040 scrollInputToEnd(targetInput);
21041
21042 if (targetInput === pathInput) {
21043 updateReportTitleFromPath();
21044 autoSetOutputDir(data.selected_path);
21045 fetchProjectHistory(data.selected_path);
21046 loadPreview();
21047 suggestCoverageFile(data.selected_path);
21048 }
21049
21050 updateReview();
21051 } else if (targetInput === pathInput) {
21052 loadPreview();
21053 }
21054 })
21055 .catch(function () {
21056 window.alert("Directory picker request failed.");
21057 if (previewPanel && targetInput === pathInput) {
21058 previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
21059 }
21060 })
21061 .finally(function () {
21062 if (browseButton) browseButton.disabled = false;
21063 });
21064 }
21065
21066 if (themeToggle) {
21067 themeToggle.addEventListener("click", function () {
21068 var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
21069 applyTheme(nextTheme);
21070 try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
21071 });
21072 }
21073
21074 stepButtons.forEach(function (button) {
21075 button.addEventListener("click", function () {
21076 var target = Number(button.getAttribute("data-step-target"));
21077 // Block jumping forward off step 1 while the preview / upload is running
21078 // or while a multi-repository selection is unacknowledged.
21079 if (step1ForwardBlocked() && currentStep === 1 && target > 1) return;
21080 setStep(target);
21081 });
21082 });
21083
21084 Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
21085 button.addEventListener("click", function () {
21086 var target = Number(button.getAttribute("data-step-target")) || 1;
21087 if (step1ForwardBlocked() && currentStep === 1 && target > 1) return;
21088 setStep(target);
21089 });
21090 });
21091
21092 // True when the project path is untouched from the bundled sample default.
21093 function isDefaultSamplePath() {
21094 return !GIT_MODE && pathInput && pathInput.value.trim() === "testing/fixtures/basic";
21095 }
21096
21097 var defaultPathOverlay = document.getElementById("default-path-overlay");
21098 function closeDefaultPathModal() {
21099 if (defaultPathOverlay) defaultPathOverlay.classList.remove("open");
21100 }
21101 function openDefaultPathModal() {
21102 if (defaultPathOverlay) defaultPathOverlay.classList.add("open");
21103 }
21104
21105 Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
21106 // Skip buttons that aren't real wizard navigation (e.g. modal action buttons
21107 // that borrow the .next-step style class but carry no data-next target).
21108 if (!button.hasAttribute("data-next")) return;
21109 button.addEventListener("click", function () {
21110 // Guard step 1 → 2: block while the scope preview / upload is still running
21111 // or while a multi-repository selection is unacknowledged.
21112 if (button.getAttribute("data-next") === "2" && step1ForwardBlocked()) return;
21113 // Guard step 1 → 2: warn when the project path is still the sample default.
21114 if (button.getAttribute("data-next") === "2" && isDefaultSamplePath()) {
21115 openDefaultPathModal();
21116 return;
21117 }
21118 updateReview();
21119 setStep(Number(button.getAttribute("data-next")));
21120 });
21121 });
21122
21123 Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
21124 if (!button.hasAttribute("data-prev")) return;
21125 button.addEventListener("click", function () {
21126 setStep(Number(button.getAttribute("data-prev")));
21127 });
21128 });
21129
21130 // Default-sample-path confirmation modal wiring.
21131 var defaultPathProceed = document.getElementById("default-path-proceed");
21132 if (defaultPathProceed) {
21133 defaultPathProceed.addEventListener("click", function () {
21134 closeDefaultPathModal();
21135 updateReview();
21136 setStep(2);
21137 });
21138 }
21139 var defaultPathCancel = document.getElementById("default-path-cancel");
21140 if (defaultPathCancel) {
21141 defaultPathCancel.addEventListener("click", function () {
21142 closeDefaultPathModal();
21143 if (pathInput) { pathInput.focus(); pathInput.select(); }
21144 });
21145 }
21146 if (defaultPathOverlay) {
21147 defaultPathOverlay.addEventListener("click", function (e) {
21148 if (e.target === defaultPathOverlay) closeDefaultPathModal();
21149 });
21150 }
21151 document.addEventListener("keydown", function (e) {
21152 if (e.key === "Escape" && defaultPathOverlay && defaultPathOverlay.classList.contains("open")) {
21153 closeDefaultPathModal();
21154 }
21155 });
21156
21157 document.addEventListener("keydown", function (e) {
21158 var tag = (document.activeElement || {}).tagName || "";
21159 if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
21160 if (e.altKey || e.ctrlKey || e.metaKey) return;
21161 if (e.key === "ArrowRight" && currentStep < 4) {
21162 if (currentStep === 1 && step1ForwardBlocked()) return;
21163 if (currentStep === 1 && isDefaultSamplePath()) { openDefaultPathModal(); return; }
21164 updateReview(); setStep(currentStep + 1);
21165 }
21166 else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
21167 });
21168
21169 if (useSamplePath) {
21170 useSamplePath.addEventListener("click", function () {
21171 pathInput.value = "testing/fixtures/basic";
21172 updateReportTitleFromPath();
21173 autoSetOutputDir("testing/fixtures/basic");
21174 loadPreview();
21175 suggestCoverageFile("testing/fixtures/basic");
21176 });
21177 }
21178
21179 if (useDefaultOutput) {
21180 useDefaultOutput.addEventListener("click", function () {
21181 delete outputDirInput.dataset.userEdited;
21182 autoSetOutputDir(pathInput ? pathInput.value : "");
21183 updateReview();
21184 });
21185 }
21186
21187 if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
21188 if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
21189
21190 // ── Drag-and-drop directory upload (server mode only) ─────────────────
21191 // Dropping a folder onto the path field bypasses Chrome's
21192 // "Upload X files to this site?" confirmation dialog.
21193 async function readDirRecursively(dirEntry, basePath) {
21194 var reader = dirEntry.createReader();
21195 var all = [];
21196 for (;;) {
21197 var batch = await new Promise(function(res) { reader.readEntries(res, function() { res([]); }); });
21198 if (!batch.length) break;
21199 for (var i = 0; i < batch.length; i++) all.push(batch[i]);
21200 }
21201 var SKIP = new Set(['node_modules','.git','.hg','vendor','dist','build','target','__pycache__','.svn','.idea','.vscode']);
21202 var out = [];
21203 for (var i = 0; i < all.length; i++) {
21204 var sub = all[i];
21205 if (sub.isFile) {
21206 var f = await new Promise(function(res) { sub.file(res); });
21207 out.push({ file: f, path: basePath + '/' + sub.name });
21208 } else if (sub.isDirectory && !SKIP.has(sub.name)) {
21209 var nested = await readDirRecursively(sub, basePath + '/' + sub.name);
21210 for (var j = 0; j < nested.length; j++) out.push(nested[j]);
21211 }
21212 }
21213 return out;
21214 }
21215
21216 function setupPathDropZone() {
21217 if (!SERVER_MODE || !pathInput) return;
21218 var CODE_EXTS = new Set([
21219 'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
21220 'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
21221 'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
21222 'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
21223 'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
21224 'asm','s','S','lisp','el','rkt','ml','mli','tf','hcl','proto','thrift','graphql','gql'
21225 ]);
21226 pathInput.addEventListener('dragover', function(e) {
21227 e.preventDefault();
21228 pathInput.classList.add('drag-over');
21229 });
21230 pathInput.addEventListener('dragleave', function() { pathInput.classList.remove('drag-over'); });
21231 pathInput.addEventListener('drop', function(e) {
21232 e.preventDefault();
21233 pathInput.classList.remove('drag-over');
21234 var items = e.dataTransfer.items;
21235 if (!items || !items.length) return;
21236 var dirEntry = null;
21237 for (var i = 0; i < items.length; i++) {
21238 var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
21239 if (entry && entry.isDirectory) { dirEntry = entry; break; }
21240 }
21241 if (!dirEntry) { showBannerToast('Drop a project folder (not individual files).', true); return; }
21242 var btn = browsePath;
21243 if (btn) btn.disabled = true;
21244 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Reading folder contents\u2026</div>';
21245
21246 readDirRecursively(dirEntry, dirEntry.name).then(async function(allEntries) {
21247 var total = allEntries.length;
21248 var codeEntries = allEntries.filter(function(e) {
21249 var n = e.file.name;
21250 if (n === 'Makefile' || n === 'Dockerfile' || n === 'Gemfile' || n === 'Rakefile' || n === 'Procfile' || n === 'Justfile') return true;
21251 var dot = n.lastIndexOf('.');
21252 return dot >= 0 && CODE_EXTS.has(n.slice(dot + 1).toLowerCase());
21253 });
21254 var kept = codeEntries.length;
21255 if (kept === 0) {
21256 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">No supported source files found (' + total.toLocaleString() + ' files scanned).</div>';
21257 if (btn) btn.disabled = false; return;
21258 }
21259
21260 function finish(tmpPath, sizes) {
21261 pathInput.value = tmpPath;
21262 scrollInputToEnd(pathInput);
21263 if (sizes) {
21264 window._lastUploadSizes = sizes;
21265 var sizeText = document.getElementById('project-size-text');
21266 var sizeBtn = document.getElementById('project-size-btn');
21267 if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
21268 ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
21269 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
21270 ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
21271 }
21272 updateReportTitleFromPath();
21273 autoSetOutputDir(tmpPath);
21274 fetchProjectHistory(tmpPath);
21275 loadPreview();
21276 suggestCoverageFile(tmpPath);
21277 updateReview();
21278 if (btn) btn.disabled = false;
21279 }
21280
21281 if (typeof CompressionStream === 'undefined') {
21282 showBannerToast('Your browser lacks CompressionStream. Use the \u201cUpload\u201d button instead.', true);
21283 if (btn) btn.disabled = false; return;
21284 }
21285
21286 try {
21287 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
21288 var BLOCK = 512;
21289 var cs = new CompressionStream('gzip');
21290 var wtr = cs.writable.getWriter();
21291 var chunks = [];
21292 var rdr = cs.readable.getReader();
21293 var collecting = (async function() { while (true) { var r = await rdr.read(); if (r.done) break; chunks.push(r.value); } })();
21294
21295 function buildHdr(fp, sz) {
21296 var hdr = new Uint8Array(BLOCK);
21297 var enc = new TextEncoder();
21298 function wS(o, l, s) { var b = enc.encode(s); for (var i = 0; i < Math.min(b.length, l); i++) hdr[o + i] = b[i]; }
21299 function wO(o, l, v) { var s = v.toString(8); while (s.length < l - 1) s = '0' + s; wS(o, l, s + '\0'); }
21300 var nm = fp, pfx = '';
21301 if (fp.length > 99) { var sp = fp.lastIndexOf('/', 154); if (sp > 0 && fp.length - sp - 1 <= 99) { pfx = fp.substring(0, sp); nm = fp.substring(sp + 1); } else { nm = fp.substring(0, 99); } }
21302 wS(0,100,nm); wO(100,8,0o000644); wO(108,8,0); wO(116,8,0); wO(124,12,sz); wO(136,12,0);
21303 for (var i = 148; i < 156; i++) hdr[i] = 32;
21304 hdr[156] = 48; wS(157,100,''); wS(257,6,'ustar'); wS(263,2,'00'); wS(265,32,''); wS(297,32,''); wO(329,8,0); wO(337,8,0); wS(345,155,pfx);
21305 var chk = 0; for (var i = 0; i < BLOCK; i++) chk += hdr[i];
21306 var cv = chk.toString(8); while (cv.length < 6) cv = '0' + cv; wS(148,8,cv+'\0 ');
21307 return hdr;
21308 }
21309
21310 for (var i = 0; i < codeEntries.length; i++) {
21311 var ce = codeEntries[i];
21312 var buf = await ce.file.arrayBuffer();
21313 var data = new Uint8Array(buf);
21314 await wtr.write(buildHdr(ce.path, data.length));
21315 if (data.length > 0) { var padded = Math.ceil(data.length / BLOCK) * BLOCK; var blk = new Uint8Array(padded); blk.set(data); await wtr.write(blk); }
21316 if ((i + 1) % 50 === 0 || i === codeEntries.length - 1)
21317 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i+1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
21318 }
21319 await wtr.write(new Uint8Array(BLOCK * 2));
21320 await wtr.close();
21321 await collecting;
21322
21323 var blob = new Blob(chunks, { type: 'application/gzip' });
21324 var sizeMB = (blob.size / 1048576).toFixed(1);
21325 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + kept.toLocaleString() + ' files)\u2026</div>';
21326 var resp = await fetch('/api/upload-tarball', { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: blob });
21327 var d = await resp.json();
21328 if (d && d.tmp_path) {
21329 finish(d.tmp_path, { compressed_bytes: d.compressed_bytes || 0, original_bytes: d.original_bytes || 0 });
21330 } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (btn) btn.disabled = false; }
21331 } catch (err) {
21332 showBannerToast('Upload failed: ' + String(err), true);
21333 if (btn) btn.disabled = false;
21334 }
21335 }).catch(function(err) {
21336 showBannerToast('Could not read folder: ' + String(err), true);
21337 if (btn) btn.disabled = false;
21338 });
21339 });
21340 }
21341 setupPathDropZone();
21342 if (browseCoverage) {
21343 browseCoverage.addEventListener("click", function () {
21344 pickDirectory(coverageInput || pathInput, "coverage");
21345 });
21346 }
21347
21348 function setCovStatus(state, opts) {
21349 if (!covScanStatus) return;
21350 opts = opts || {};
21351 covScanStatus.className = "cov-scan-status cov-scan-" + state;
21352 if (state === "idle") { covScanStatus.innerHTML = ""; return; }
21353 var ICON_SCAN = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>';
21354 var ICON_OK = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M8 12l3 3 5-5"/></svg>';
21355 var ICON_WARN = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="9"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>';
21356 var ICON_NONE = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="9"/><line x1="9" y1="9" x2="15" y2="15"/><line x1="15" y1="9" x2="9" y2="15"/></svg>';
21357 var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
21358 var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
21359 if (state === "scanning") {
21360 html += '<div class="cov-scan-title">Scanning project for coverage files\u2026</div>';
21361 } else if (state === "found") {
21362 var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
21363 html += '<div class="cov-scan-title">Coverage file auto-detected! ' + tb + '</div>';
21364 html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
21365 html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove</button></div>';
21366 } else if (state === "hint") {
21367 var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
21368 html += '<div class="cov-scan-title">' + tb2 + ' project — no coverage report found yet</div>';
21369 html += '<div class="cov-scan-sub">Generate a report with your test framework\'s coverage tool, then browse to the output file. Supported: LCOV .info · Cobertura XML · JaCoCo XML · coverage.py JSON · Istanbul JSON</div>';
21370 } else if (state === "none") {
21371 html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
21372 html += '<div class="cov-scan-sub">Supported: LCOV\u00a0.info · Cobertura\u00a0XML · JaCoCo\u00a0XML · coverage.py\u00a0JSON · Istanbul\u00a0JSON</div>';
21373 }
21374 html += '</div></div>';
21375 covScanStatus.innerHTML = html;
21376 if (state === "found") {
21377 var useBtn = covScanStatus.querySelector(".cov-scan-use");
21378 if (useBtn) useBtn.addEventListener("click", function () {
21379 if (coverageInput) coverageInput.value = "";
21380 covAutoFilled = false;
21381 setCovStatus("idle");
21382 });
21383 }
21384 }
21385
21386 function suggestCoverageFile(projectPath) {
21387 if (!coverageInput || !covScanStatus) return;
21388 if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
21389 if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
21390 clearTimeout(coverageSuggestTimer);
21391 if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
21392 setCovStatus("scanning");
21393 coverageSuggestTimer = setTimeout(function () {
21394 fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
21395 .then(function (r) { return r.json(); })
21396 .then(function (d) {
21397 if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
21398 if (!d) { setCovStatus("none"); return; }
21399 if (d.found) {
21400 if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
21401 setCovStatus("found", { found: d.found, tool: d.tool });
21402 } else if (d.tool && d.hint) {
21403 setCovStatus("hint", { tool: d.tool, hint: d.hint });
21404 } else {
21405 setCovStatus("none");
21406 }
21407 })
21408 .catch(function () { setCovStatus("idle"); });
21409 }, 600);
21410 }
21411
21412 if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
21413
21414 if (coverageInput) coverageInput.addEventListener("input", function () {
21415 covAutoFilled = false;
21416 if (!this.value.trim()) setCovStatus("idle");
21417 });
21418
21419 // ── Language pill overflow: collapse to "+N more" chip ─────────────
21420 function collapseLanguagePills() {
21421 var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
21422 rows.forEach(function(row) {
21423 // Remove any previous overflow chip
21424 var prev = row.querySelector('.lang-overflow-chip');
21425 if (prev) prev.remove();
21426 var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
21427 pills.forEach(function(p) { p.style.display = ''; });
21428 if (!pills.length) return;
21429
21430 // Measure after restoring all pills
21431 var containerRight = row.getBoundingClientRect().right;
21432 var hidden = [];
21433 for (var i = pills.length - 1; i >= 1; i--) {
21434 var rect = pills[i].getBoundingClientRect();
21435 if (rect.right > containerRight + 2) {
21436 hidden.unshift(pills[i]);
21437 pills[i].style.display = 'none';
21438 } else {
21439 break;
21440 }
21441 }
21442
21443 if (hidden.length) {
21444 var chip = document.createElement('button');
21445 chip.type = 'button';
21446 chip.className = 'language-pill lang-overflow-chip';
21447 var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
21448 chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
21449 row.appendChild(chip);
21450 }
21451 });
21452 }
21453
21454 // Run after preview loads (preview panel populates language pills)
21455 var _origLoadPreviewCb = window.__previewLoaded;
21456 document.addEventListener('previewLoaded', collapseLanguagePills);
21457 window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
21458 setTimeout(collapseLanguagePills, 400);
21459
21460 // ── Project history & output dir auto-set ──────────────────────────
21461 var wsOutputRoot = document.getElementById("ws-output-root");
21462 var wsScanCount = document.getElementById("ws-scan-count");
21463 var wsLastScan = document.getElementById("ws-last-scan");
21464 var historyBadge = document.getElementById("path-history-badge");
21465 var historyTimer = null;
21466
21467 var wsOutputLink = document.getElementById("ws-output-link");
21468 function syncStripOutputRoot() {
21469 var val = outputDirInput ? outputDirInput.value : "";
21470 var display = val || "project/sloc";
21471 if (wsOutputRoot) wsOutputRoot.textContent = display;
21472 if (wsOutputLink) wsOutputLink.dataset.folder = val;
21473 }
21474
21475 function scrollInputToEnd(input) {
21476 if (!input) return;
21477 // Defer so the DOM has the new value before we measure scroll width.
21478 requestAnimationFrame(function () {
21479 input.scrollLeft = input.scrollWidth;
21480 input.selectionStart = input.selectionEnd = input.value.length;
21481 });
21482 }
21483
21484 function autoSetOutputDir(projectPath) {
21485 if (!outputDirInput || outputDirInput.dataset.userEdited) return;
21486 if (GIT_MODE && GIT_OUTPUT_DIR) {
21487 outputDirInput.value = GIT_OUTPUT_DIR;
21488 scrollInputToEnd(outputDirInput);
21489 syncStripOutputRoot();
21490 updateReview();
21491 return;
21492 }
21493 if (!projectPath || !projectPath.trim()) return;
21494 var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
21495 outputDirInput.value = cleaned + "/sloc";
21496 scrollInputToEnd(outputDirInput);
21497 syncStripOutputRoot();
21498 updateReview();
21499 }
21500
21501 var wsBranch = document.getElementById("ws-branch");
21502
21503 function fetchProjectHistory(projectPath) {
21504 if (!projectPath || !projectPath.trim()) {
21505 if (wsScanCount) wsScanCount.textContent = "\u2014";
21506 if (wsLastScan) wsLastScan.textContent = "\u2014";
21507 if (wsBranch) wsBranch.textContent = "\u2014";
21508 if (historyBadge) historyBadge.style.display = "none";
21509 return;
21510 }
21511 fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
21512 .then(function (r) { return r.ok ? r.json() : null; })
21513 .then(function (data) {
21514 if (!data) return;
21515 var countStr = data.scan_count > 0
21516 ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
21517 : "never";
21518 var tsStr = data.last_scan_timestamp
21519 ? data.last_scan_timestamp.replace(" UTC","")
21520 : "\u2014";
21521 if (wsScanCount) wsScanCount.textContent = countStr;
21522 if (wsLastScan) wsLastScan.textContent = tsStr;
21523 if (wsBranch) wsBranch.textContent = data.last_git_branch || "\u2014";
21524 if (data.scan_count > 0) {
21525 if (historyBadge) {
21526 var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
21527 historyBadge.textContent = data.scan_count + " previous scan" +
21528 (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
21529 "Last: " + (data.last_scan_timestamp || "\u2014") +
21530 " \u2014 " + (data.last_scan_code_lines ? (function(v){return v>=1e6?(v/1e6).toFixed(1).replace(/\.0$/,'')+'M':v>=1e4?(v/1e3).toFixed(1).replace(/\.0$/,'')+'K':Number(v).toLocaleString();})(data.last_scan_code_lines) : "?") + " code lines.";
21531 historyBadge.className = "path-history-badge found";
21532 historyBadge.style.display = "";
21533 }
21534 } else {
21535 if (historyBadge) historyBadge.style.display = "none";
21536 }
21537 })
21538 .catch(function () {});
21539 }
21540
21541 function onPathChange() {
21542 var val = pathInput ? pathInput.value : "";
21543 // Discard stale upload sizes when the user edits the path manually.
21544 window._lastUploadSizes = null;
21545 updateReportTitleFromPath();
21546 autoSetOutputDir(val);
21547 updateSidebarSummary();
21548 clearTimeout(historyTimer);
21549 historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
21550 if (previewTimer) clearTimeout(previewTimer);
21551 previewTimer = setTimeout(loadPreview, 280);
21552 suggestCoverageFile(val);
21553 }
21554
21555 if (pathInput) {
21556 pathInput.addEventListener("input", onPathChange);
21557 }
21558
21559 if (outputDirInput) {
21560 outputDirInput.addEventListener("input", function () {
21561 outputDirInput.dataset.userEdited = "1";
21562 syncStripOutputRoot();
21563 updateReview();
21564 });
21565 }
21566
21567 [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
21568 if (!node) return;
21569 node.addEventListener("input", function () {
21570 updateReview();
21571 if (previewTimer) clearTimeout(previewTimer);
21572 previewTimer = setTimeout(loadPreview, 280);
21573 });
21574 });
21575
21576 ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
21577 var node = document.getElementById(id);
21578 if (node) node.addEventListener("change", updateReview);
21579 });
21580
21581 if (reportTitleInput) {
21582 reportTitleInput.addEventListener("input", function () {
21583 reportTitleTouched = reportTitleInput.value.trim().length > 0;
21584 updateReportTitleFromPath();
21585 updateReview();
21586 });
21587 }
21588
21589 if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
21590 if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
21591 if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
21592 if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
21593
21594 if (coverageInput) {
21595 coverageInput.addEventListener("input", function () {
21596 if (coverageInput.value.trim()) setCovStatus("idle");
21597 });
21598 }
21599
21600 if (form && loading && submitButton) {
21601 form.addEventListener("submit", function (e) {
21602 e.preventDefault();
21603 submitButton.disabled = true;
21604 submitButton.textContent = "Scanning...";
21605 startAsyncAnalysis(new FormData(form));
21606 });
21607 }
21608
21609 function openPath(folder) {
21610 if (!folder) return;
21611 fetch('/open-path?path=' + encodeURIComponent(folder))
21612 .then(function (r) { return r.json(); })
21613 .then(function (d) {
21614 if (d && d.server_mode_disabled)
21615 showBannerToast(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
21616 })
21617 .catch(function () {});
21618 }
21619
21620 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
21621 btn.addEventListener('click', function () {
21622 openPath(btn.getAttribute('data-folder') || btn.dataset.folder || '');
21623 });
21624 });
21625
21626 // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
21627 if (wsOutputLink) {
21628 wsOutputLink.addEventListener('click', function () {
21629 openPath(wsOutputLink.dataset.folder || '');
21630 });
21631 }
21632
21633 loadSavedTheme();
21634 updateMixedPolicyUI();
21635 updatePythonDocstringUI();
21636 applyScanPreset();
21637 updatePresetDescriptions();
21638 applyArtifactPreset();
21639 updateReview();
21640 updateScrollProgress(); // initialise bar to 0% (step 1)
21641 window.addEventListener("scroll", updateScrollProgress, { passive: true });
21642 onPathChange(); // seed output dir, history badge, and preview from initial path
21643 updateStepNav(1);
21644
21645 // Restore step from URL hash on initial load (e.g., back-forward cache)
21646 (function() {
21647 var hashMatch = location.hash.match(/^#step([1-4])$/);
21648 if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
21649 })();
21650
21651 (function randomizeWatermarks() {
21652 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
21653 if (!wms.length) return;
21654 var placed = [];
21655 function tooClose(top, left) {
21656 for (var i = 0; i < placed.length; i++) {
21657 var dt = Math.abs(placed[i][0] - top);
21658 var dl = Math.abs(placed[i][1] - left);
21659 if (dt < 16 && dl < 12) return true;
21660 }
21661 return false;
21662 }
21663 function pick(leftBand) {
21664 for (var attempt = 0; attempt < 50; attempt++) {
21665 var top = Math.random() * 88 + 2;
21666 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21667 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
21668 }
21669 var top = Math.random() * 88 + 2;
21670 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21671 placed.push([top, left]);
21672 return [top, left];
21673 }
21674 var half = Math.floor(wms.length / 2);
21675 wms.forEach(function (img, i) {
21676 var pos = pick(i < half);
21677 var size = Math.floor(Math.random() * 80 + 110);
21678 var rot = (Math.random() * 360).toFixed(1);
21679 var op = (Math.random() * 0.08 + 0.13).toFixed(2);
21680 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;
21681 });
21682 })();
21683
21684 (function spawnCodeParticles() {
21685 var container = document.getElementById('code-particles');
21686 if (!container) return;
21687 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'];
21688 for (var i = 0; i < 38; i++) {
21689 (function(idx) {
21690 var el = document.createElement('span');
21691 el.className = 'code-particle';
21692 el.textContent = snippets[idx % snippets.length];
21693 var left = Math.random() * 94 + 2;
21694 var top = Math.random() * 88 + 6;
21695 var dur = (Math.random() * 10 + 9).toFixed(1);
21696 var delay = (Math.random() * 18).toFixed(1);
21697 var rot = (Math.random() * 26 - 13).toFixed(1);
21698 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
21699 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';
21700 container.appendChild(el);
21701 })(i);
21702 }
21703 })();
21704 })();
21705 </script>
21706 <script nonce="{{ csp_nonce }}">
21707 (function () {
21708 var raw = {{ prefill_json|safe }};
21709 if (!raw || typeof raw !== 'object' || !raw.path) return;
21710 function setVal(id, val) { var el = document.getElementById(id); if (el) { el.value = val; if (id === 'output_dir') scrollInputToEnd(el); } }
21711 function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
21712 function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
21713 setVal('path', raw.path || '');
21714 setVal('include_globs', raw.include_globs || '');
21715 setVal('exclude_globs', raw.exclude_globs || '');
21716 setVal('output_dir', raw.output_dir || '');
21717 setVal('report_title', raw.report_title || '');
21718 if (raw.submodule_breakdown) setChecked('submodule_breakdown', true);
21719 setSelect('mixed_line_policy', raw.mixed_line_policy || 'code_only');
21720 setChecked('python_docstrings_as_comments', !!raw.python_docstrings_as_comments);
21721 setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
21722 setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
21723 setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
21724 if (raw.include_lockfiles) setSelect('include_lockfiles', 'enabled');
21725 setSelect('binary_file_behavior', raw.binary_file_behavior || 'skip');
21726 setChecked('generate_html', raw.generate_html !== false);
21727 setChecked('generate_pdf', !!raw.generate_pdf);
21728 if (raw.continuation_line_policy) setSelect('continuation_line_policy', raw.continuation_line_policy);
21729 if (raw.blank_in_block_comment_policy) setSelect('blank_in_block_comment_policy', raw.blank_in_block_comment_policy);
21730 setSelect('count_compiler_directives', raw.count_compiler_directives === false ? 'disabled' : 'enabled');
21731 setSelect('style_analysis_enabled', raw.style_analysis_enabled === false ? 'disabled' : 'enabled');
21732 if (raw.style_col_threshold) setSelect('style_col_threshold', String(raw.style_col_threshold));
21733 if (raw.style_score_threshold) setSelect('style_score_threshold', String(raw.style_score_threshold));
21734 if (raw.style_lang_scope) setSelect('style_lang_scope', raw.style_lang_scope);
21735 if (raw.coverage_file) setVal('coverage_file', raw.coverage_file);
21736 if (raw.cocomo_mode) setSelect('cocomo_mode', raw.cocomo_mode);
21737 if (raw.complexity_alert) setVal('complexity_alert', String(raw.complexity_alert));
21738 if (raw.activity_window !== undefined && raw.activity_window !== null) setVal('activity_window', String(raw.activity_window));
21739 setSelect('exclude_duplicates', raw.exclude_duplicates ? 'enabled' : 'disabled');
21740 // Trigger dynamic UI updates after pre-fill.
21741 setTimeout(function () {
21742 var pathEl = document.getElementById('path');
21743 if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
21744 var policyEl = document.getElementById('mixed_line_policy');
21745 if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
21746 }, 80);
21747 })();
21748 </script>
21749 <script nonce="{{ csp_nonce }}">
21750 (function(){
21751 var S=[{n:'Classic',a:'#b85d33',b:'#7a371b'},{n:'Navy',a:'#283790',b:'#1e1e24'},{n:'Ember',a:'#ce5d3d',b:'#1e1e24'},{n:'Ocean',a:'#1f439b',b:'#1e1e24'},{n:'Royal',a:'#003184',b:'#1e1e24'}];
21752 function ap(s){document.documentElement.style.setProperty('--nav',s.a);document.documentElement.style.setProperty('--nav-2',s.b);try{localStorage.setItem('sloc-ns',JSON.stringify(s));}catch(e){}document.querySelectorAll('.scheme-swatch').forEach(function(x){x.classList.toggle('active',x.dataset.n===s.n);});}
21753 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
21754 function init(){
21755 var btn=document.getElementById('settings-btn');if(!btn)return;
21756 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
21757 m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
21758 document.body.appendChild(m);
21759 var g=document.getElementById('scheme-grid');
21760 if(g)S.forEach(function(s){var el=document.createElement('button');el.type='button';el.className='scheme-swatch';el.dataset.n=s.n;el.title=s.n;var p=document.createElement('div');p.className='scheme-preview';p.style.background='linear-gradient(135deg,'+s.a+','+s.b+')';var l=document.createElement('span');l.className='scheme-label';l.textContent=s.n;el.appendChild(p);el.appendChild(l);try{var c=JSON.parse(localStorage.getItem('sloc-ns'));if(c&&c.n===s.n)el.classList.add('active');}catch(e){}el.addEventListener('click',function(){ap(s);});g.appendChild(el);});
21761 var cl=document.getElementById('settings-close');
21762 window.tzAbbr=function(z){return{'America/Los_Angeles':'PT','America/Denver':'MT','America/Chicago':'CT','America/New_York':'ET','America/Anchorage':'AT','Pacific/Honolulu':'HT'}[z]||'PT';};window.tzCity=function(z){return{'America/Los_Angeles':'Los Angeles','America/Denver':'Denver','America/Chicago':'Chicago','America/New_York':'New York','America/Anchorage':'Anchorage','Pacific/Honolulu':'Honolulu'}[z]||'';};window.tzOffset=function(z){var r='';try{var p=new Intl.DateTimeFormat('en-US',{timeZone:z,timeZoneName:'longOffset'}).formatToParts(new Date());p.forEach(function(x){if(x.type==='timeZoneName')r=x.value.replace('GMT','UTC');});}catch(e){}return r;};window.tf24=function(){try{return localStorage.getItem('sloc-tf')!=='12';}catch(e){return true;}};window.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';var h24=window.tf24();try{var pts=new Intl.DateTimeFormat('en-US',{timeZone:tz,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:!h24}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});var t=v.hour+':'+v.minute;if(!h24&&v.dayPeriod)t+=' '+v.dayPeriod;return v.year+'-'+v.month+'-'+v.day+' '+t+' '+window.tzAbbr(tz);}catch(e){return'';}};window.enhanceTzOptions=function(sel){if(!sel)return;Array.prototype.forEach.call(sel.options,function(o){var base=o.textContent.split(' - ')[0];var city=window.tzCity(o.value);var off=window.tzOffset(o.value);o.textContent=base+(city?' - '+city:'')+(off?' - '+off:'');});};window.applyTz=function(tz){try{localStorage.setItem('sloc-tz',tz);}catch(e){}document.querySelectorAll('[data-utc-ms]').forEach(function(el){var ms=parseInt(el.getAttribute('data-utc-ms'),10);if(!isNaN(ms))el.textContent=window.fmtTz(ms,tz);});};window.applyTf=function(tf){try{localStorage.setItem('sloc-tf',tf);}catch(e){}var z;try{z=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){z='America/Los_Angeles';}window.applyTz(z);};var tzSel=document.getElementById('tz-select');window.enhanceTzOptions(tzSel);var storedTz;try{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){storedTz='America/Los_Angeles';}if(tzSel){tzSel.value=storedTz;tzSel.addEventListener('change',function(){window.applyTz(this.value);});}window.applyTz(storedTz);(function(){var tzp=document.getElementById('tz-select');if(!tzp||document.getElementById('tf-select')||!tzp.parentNode)return;var tw=document.createElement('div');tw.style.marginTop='10px';var tl=document.createElement('div');tl.className='settings-modal-label';tl.style.marginBottom='8px';tl.textContent='Time format';var tfSel=document.createElement('select');tfSel.className='tz-select';tfSel.id='tf-select';tfSel.innerHTML='<option value="24">24-hour (14:30)</option><option value="12">12-hour (2:30 PM)</option>';tw.appendChild(tl);tw.appendChild(tfSel);tzp.parentNode.appendChild(tw);var storedTf;try{storedTf=localStorage.getItem('sloc-tf')||'24';}catch(e){storedTf='24';}tfSel.value=storedTf;tfSel.addEventListener('change',function(){window.applyTf(this.value);});})();
21763 btn.addEventListener('click',function(e){e.stopPropagation();var r=btn.getBoundingClientRect();m.style.top=(r.bottom+6)+'px';m.style.right=(window.innerWidth-r.right)+'px';m.classList.toggle('open');});
21764 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
21765 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
21766 }
21767 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
21768 }());
21769 </script>
21770 <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
21771 <div class="wb-ftip-arrow"></div>
21772 <span id="wb-ftip-text"></span>
21773 </div>
21774 <script nonce="{{ csp_nonce }}">(function(){
21775 var tip=document.getElementById('wb-ftip');
21776 var txt=document.getElementById('wb-ftip-text');
21777 var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
21778 if(!tip||!txt)return;
21779 function pos(el){
21780 var r=el.getBoundingClientRect();
21781 tip.style.display='block';
21782 var tw=tip.offsetWidth;
21783 var lx=r.left+r.width/2-tw/2;
21784 if(lx<8)lx=8;
21785 if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
21786 tip.style.left=lx+'px';
21787 tip.style.top=(r.bottom+8)+'px';
21788 if(arr){var al=r.left+r.width/2-lx-6;al=Math.max(10,Math.min(tw-22,al));arr.style.left=al+'px';}
21789 }
21790 document.querySelectorAll('[data-wb-tip]').forEach(function(el){
21791 el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
21792 el.addEventListener('mouseleave',function(){tip.style.display='none';});
21793 });
21794 window.addEventListener('blur',function(){tip.style.display='none';});
21795 document.addEventListener('visibilitychange',function(){if(document.hidden)tip.style.display='none';});
21796 })();
21797 (function(){
21798 function fixArtifactHintSpacing(){
21799 var grid=document.querySelector('.artifact-grid');
21800 if(grid){grid.style.setProperty('margin-bottom','48px','important');}
21801 }
21802 if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
21803 }());
21804 (function(){
21805 var dot=document.getElementById('status-dot');
21806 var pingEl=document.getElementById('server-ping-ms');
21807 var tipEl=document.getElementById('server-tip-ping');
21808 var fm=document.getElementById('footer-mode');
21809 function setDotColor(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}
21810 function doPing(){
21811 var t0=performance.now();
21812 fetch('/healthz',{cache:'no-store'})
21813 .then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDotColor(ms);})
21814 .catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});
21815 }
21816 doPing();
21817 setInterval(doPing,5000);
21818 if(fm){var isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';fm.textContent='oxide-sloc v{{ version }} \u2014 Mode: '+(isServer?'Network Server':'Local');}
21819 })();
21820 </script>
21821 <span id="page-bottom" aria-hidden="true" style="display:block;height:0;"></span>
21822 <footer class="site-footer">
21823 local code analysis - metrics, history and reports
21824 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: {% if server_mode %}Network Server{% else %}Local{% endif %}</em>
21825 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
21826 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
21827 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
21828 · <a href="/api-docs" rel="noopener">REST API</a>
21829 </footer>
21830</body>
21831</html>
21832"##,
21833 ext = "html"
21834)]
21835struct IndexTemplate {
21836 version: &'static str,
21837 prefill_json: String,
21838 csp_nonce: String,
21839 git_repo: String,
21840 git_ref: String,
21841 git_label_json: String,
21842 git_output_dir_json: String,
21843 server_mode: bool,
21844}
21845
21846#[derive(Template)]
21849#[template(
21850 source = r##"
21851<!doctype html>
21852<html lang="en">
21853<head>
21854 <meta charset="utf-8">
21855 <meta name="viewport" content="width=device-width, initial-scale=1">
21856 <title>OxideSLOC — local code analysis - metrics, history and reports</title>
21857 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
21858 <script type="application/ld+json">
21859 {
21860 "@context": "https://schema.org",
21861 "@type": "SoftwareApplication",
21862 "name": "oxide-sloc",
21863 "applicationCategory": "DeveloperApplication",
21864 "operatingSystem": "Windows, Linux",
21865 "description": "IEEE 1045-1992 SLOC analysis workbench — CLI, web UI, MCP server, 60 languages, offline-first. Counts code, comment, and blank lines; detects unit tests; produces HTML and PDF reports.",
21866 "softwareVersion": "{{ version }}",
21867 "author": { "@type": "Person", "name": "Nima Shafie", "url": "https://github.com/NimaShafie" },
21868 "license": "https://www.gnu.org/licenses/agpl-3.0.html",
21869 "url": "https://github.com/oxide-sloc/oxide-sloc",
21870 "downloadUrl": "https://github.com/oxide-sloc/oxide-sloc/releases",
21871 "featureList": "60 language analysis, IEEE 1045-1992 SLOC counting, HTML and PDF reports, REST API, MCP server, CI/CD integration, trend reports, test metrics, git integration",
21872 "programmingLanguage": "Rust",
21873 "keywords": "sloc, code analysis, source lines of code, metrics, MCP, AI agent"
21874 }
21875 </script>
21876 <style nonce="{{ csp_nonce }}">
21877 :root {
21878 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
21879 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
21880 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
21881 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
21882 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
21883 }
21884 body.dark-theme {
21885 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
21886 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
21887 }
21888 *{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);} body{display:flex;flex-direction:column;}
21889 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21890 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
21891 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21892 .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;}
21893 @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));}}
21894 .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);}
21895 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
21896 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;} .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));}
21897 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
21898 .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;white-space:nowrap;}
21899 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
21900 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
21901 @media (max-width: 1150px) { .nav-right { gap: 4px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 8px; font-size: 11px; min-height: 34px; } .brand-subtitle { display: none; } .server-online-pill { width: 34px; padding: 0; justify-content: center; font-size: 0; gap: 0; min-height: 34px; } }
21902 .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;white-space:nowrap;text-decoration:none;}
21903 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
21904 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
21905 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
21906 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
21907 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
21908 .settings-modal{position:fixed;z-index:9999;background:var(--surface-2);border:1px solid var(--line-strong);border-radius:14px;box-shadow:0 12px 36px rgba(0,0,0,0.22);min-width:260px;max-width:320px;opacity:0;pointer-events:none;transform:translateY(-8px) scale(0.97);transition:opacity 0.18s ease,transform 0.18s ease;overflow:hidden;}
21909 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
21910 .settings-modal-header{display:flex;align-items:center;justify-content:space-between;padding:14px 16px 10px;border-bottom:1px solid var(--line);font-size:13px;font-weight:800;color:var(--text);}
21911 .settings-close{background:none;border:none;cursor:pointer;width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--muted);border-radius:6px;padding:0;}
21912 .settings-close:hover{color:var(--text);background:var(--surface-2);}
21913 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
21914 .settings-modal-body{padding:14px 16px 16px;}
21915 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
21916 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
21917 .scheme-swatch{display:flex;flex-direction:column;align-items:center;gap:5px;background:none;border:1.5px solid var(--line);border-radius:10px;cursor:pointer;padding:7px 4px 6px;transition:border-color 0.15s ease,transform 0.12s ease;}
21918 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
21919 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
21920 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
21921 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
21922 .tz-select{width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2);color:var(--text);font-size:12px;font-weight:600;cursor:pointer;outline:none;box-sizing:border-box;}
21923 .tz-select:focus{border-color:var(--oxide);}
21924 .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;}
21925 .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;}
21926 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
21927 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
21928 .hero{text-align:center;margin:0 auto 18px;}
21929 .hero-logo-wrap{display:inline-block;cursor:default;}
21930 .hero-logo{width:66px;height:73px;object-fit:contain;margin-bottom:0;filter:drop-shadow(0 8px 22px rgba(184,93,51,0.30));display:block;}
21931 .hero-logo-shadow{width:52px;height:8px;background:radial-gradient(ellipse,rgba(211,122,76,0.55),transparent 70%);border-radius:50%;margin:0 auto 6px;}
21932 .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
21933 .hero-title-aura{position:absolute;inset:-40px -80px;background:radial-gradient(ellipse at 50% 55%,rgba(211,122,76,0.20) 0%,rgba(211,122,76,0.056) 45%,transparent 72%);pointer-events:none;z-index:0;}
21934 body.dark-theme .hero-title-aura{background:radial-gradient(ellipse at 50% 55%,rgba(211,122,76,0.29) 0%,rgba(211,122,76,0.10) 45%,transparent 72%);}
21935 .hero-title{font-size:36px;font-weight:900;letter-spacing:-0.04em;margin:0 0 6px;display:inline-block;position:relative;z-index:1;will-change:transform;transition:transform 0.08s linear;
21936 background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
21937 background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
21938 clip-path:inset(0 100% 0 0);animation:titleReveal 0.65s cubic-bezier(.4,0,.2,1) 0.12s forwards,titleShimmer 4s linear 0.82s infinite;}
21939 @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
21940 @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
21941 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;}
21942 .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
21943 .hero-cursor{display:inline-block;width:2px;height:0.9em;background:var(--oxide);vertical-align:text-bottom;margin-left:1px;border-radius:1px;animation:cursorBlink 0.72s step-end infinite;}
21944 @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
21945 .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
21946 .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
21947 .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
21948 .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
21949 @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
21950 @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
21951 .action-card{display:flex;flex-direction:column;align-items:flex-start;padding:12px 15px 10px;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;}
21952 .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;} .action-card:nth-child(5){animation-delay:0.5s;} .action-card:nth-child(6){animation-delay:0.6s;} .action-card:nth-child(7){animation-delay:0.7s;}
21953 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
21954 @media(prefers-reduced-motion:reduce){.action-card,.lan-card{animation:none;}}
21955 .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
21956 .action-card-icon{width:40px;height:40px;border-radius:12px;display:flex;align-items:center;justify-content:center;margin-bottom:8px;flex:0 0 auto;transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
21957 .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
21958 .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
21959 .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);}
21960 .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);}
21961 .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);}
21962 .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
21963 .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
21964 .action-card-cta{display:inline-flex;align-items:center;gap:7px;font-size:12px;font-weight:800;color:var(--oxide-2);transition:gap 0.15s ease;}
21965 body.dark-theme .action-card-cta{color:var(--oxide);}
21966 .action-card.view .action-card-cta{color:var(--accent-2);}
21967 body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
21968 .action-card.compare .action-card-cta{color:#7c3aed;}
21969 body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
21970 .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);}
21971 .action-card.git-tools .action-card-cta{color:#15803d;}
21972 body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
21973 .action-card.trend .action-card-icon{background:linear-gradient(135deg,#0891b2,#0e7490);color:#fff;box-shadow:0 8px 22px rgba(8,145,178,0.28);}
21974 .action-card.trend .action-card-cta{color:#0e7490;}
21975 body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
21976 .action-card.automation .action-card-icon{background:linear-gradient(135deg,#d97706,#b45309);color:#fff;box-shadow:0 8px 22px rgba(217,119,6,0.28);}
21977 .action-card.automation .action-card-cta{color:#b45309;}
21978 body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
21979 .action-card.test-metrics .action-card-icon{background:linear-gradient(135deg,#ec4899,#be185d);color:#fff;box-shadow:0 8px 22px rgba(236,72,153,0.28);}
21980 .action-card.test-metrics .action-card-cta{color:#be185d;}
21981 body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
21982 .action-card:hover .action-card-cta{gap:12px;}
21983 .action-card.card-split{flex-direction:row;align-items:stretch;}
21984 .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
21985 .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
21986 .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
21987 .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
21988 .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
21989 .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
21990 .ac-badge{display:inline-block;padding:3px 8px;border-radius:20px;font-size:10px;font-weight:700;letter-spacing:.04em;border:1px solid transparent;transition:opacity .3s;opacity:0.45;}
21991 .ac-badge.active{opacity:1;}
21992 .ac-badge.github{border-color:#555;color:#555;}
21993 .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
21994 .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
21995 .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
21996 .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
21997 body.dark-theme .ac-right-row{color:var(--muted);}
21998 body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
21999 @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
22000 .divider{height:1px;background:var(--line);margin:32px 0;}
22001 .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
22002 @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
22003 @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
22004 .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
22005 transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
22006 .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
22007 .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
22008 body.dark-theme .info-chip-val{color:var(--oxide);}
22009 .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
22010 .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
22011 background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
22012 white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
22013 .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
22014 border:6px solid transparent;border-top-color:var(--text);}
22015 .info-chip:hover .info-chip-tip{display:block;}
22016 .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
22017 .chip-slide.fading{filter:blur(5px);opacity:0;}
22018 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22019 .site-footer a{color:var(--muted);}
22020 .lan-card{border-radius:var(--radius);border:1.5px solid var(--line-strong);background:var(--surface);box-shadow:var(--shadow);padding:18px 22px;margin:0 0 20px;animation:cardRise 0.7s ease both;}
22021 .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
22022 body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
22023 .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
22024 .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;}
22025 .lan-badge.local{background:var(--oxide-2);}
22026 .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
22027 .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);}
22028 body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
22029 .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;}
22030 .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
22031 .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
22032 .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;}
22033 body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
22034 .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;}
22035 .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);}
22036 body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
22037 body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
22038 .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
22039 .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;white-space:nowrap;text-decoration:none;}.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;}
22040 @media (max-height: 1100px) {
22041 .page{padding-top:10px;}
22042 .hero{margin-bottom:10px;}
22043 .hero-logo{width:54px;height:60px;}
22044 .hero-logo-shadow{width:42px;}
22045 .hero-title{font-size:28px;}
22046 .hero-subtitle{font-size:13px;}
22047 .card-sections{gap:12px;margin-bottom:6px;}
22048 .card-section-grid-2,.card-section-grid-3{gap:10px;}
22049 .action-card{padding:8px 15px 8px;}
22050 .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
22051 .action-card-icon svg{width:18px;height:18px;}
22052 .action-card-title{font-size:13px;}
22053 .action-card-desc{font-size:11px;margin-bottom:6px;}
22054 .action-card-cta{font-size:11px;}
22055 .ac-right-row{font-size:11px;}
22056 .divider{margin:14px 0;}
22057 .info-strip{gap:7px;margin-bottom:8px;}
22058 .info-chip{padding:7px 10px;}
22059 .info-chip-val{font-size:13px;}
22060 .info-chip-label{font-size:9px;}
22061 .site-footer{padding:8px 24px;font-size:12px;}
22062 .lan-local-hint{margin-top:8px;}
22063 }
22064 @media (max-height: 850px) {
22065 .page{padding-top:6px;}
22066 .hero{margin-bottom:6px;}
22067 .hero-logo{width:42px;height:46px;}
22068 .hero-title{font-size:22px;}
22069 .hero-subtitle{font-size:12px;}
22070 .card-sections{gap:10px;}
22071 .action-card-desc{margin-bottom:4px;}
22072 .divider{margin:8px 0;}
22073 .info-strip{margin-bottom:6px;}
22074 .lan-local-hint{margin-top:10px;}
22075 }
22076 </style>
22077</head>
22078<body>
22079 <div class="background-watermarks" aria-hidden="true">
22080 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22081 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22082 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22083 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22084 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22085 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22086 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22087 </div>
22088 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22089 <div class="top-nav">
22090 <div class="top-nav-inner">
22091 <a class="brand" href="/">
22092 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
22093 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
22094 </a>
22095 <div class="nav-right">
22096 <a class="nav-pill" href="/" style="background:rgba(255,255,255,0.22);">Home</a>
22097 <div class="nav-dropdown">
22098 <a href="/view-reports" class="nav-dropdown-btn">View Reports <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></a>
22099 <div class="nav-dropdown-menu">
22100 <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
22101 </div>
22102 </div>
22103 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
22104 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22105 <div class="nav-dropdown">
22106 <a href="/git-browser" class="nav-dropdown-btn">Git Browser <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></a>
22107 <div class="nav-dropdown-menu">
22108 <a href="/integrations"><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>Integrations</a>
22109 </div>
22110 </div>
22111 <div class="server-status-wrap" id="server-status-wrap">
22112 <div class="nav-pill server-online-pill" id="server-status-pill">
22113 <span class="status-dot" id="status-dot"></span>
22114 <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
22115 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22116 </div>
22117 <div class="server-status-tip">
22118 {% if server_mode %}OxideSLOC is running in server mode — accessible on your LAN.{% else %}OxideSLOC is running locally — only accessible from this machine.{% endif %}
22119 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22120 </div>
22121 </div>
22122 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22123 <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
22124 </button>
22125 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
22126 <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>
22127 <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>
22128 </button>
22129 </div>
22130 </div>
22131 </div>
22132
22133 <div class="page">
22134 <div class="hero">
22135 <div class="hero-logo-wrap" id="hero-logo-wrap">
22136 <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
22137 </div>
22138 <div class="hero-logo-shadow"></div>
22139 <div class="hero-title-wrap">
22140 <div class="hero-title-aura" aria-hidden="true"></div>
22141 <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
22142 </div>
22143 <p class="hero-subtitle" id="hero-subtitle">A fast, self-contained local code analysis tool. Count SLOC, measure test coverage, track trends, compare snapshots, and automate scans via webhook — no setup required.</p>
22144 </div>
22145
22146 <div class="card-sections">
22147
22148 <div>
22149 <div class="card-section-label">Analysis</div>
22150 <div class="card-section-grid-2">
22151 <a class="action-card scan card-split" href="/scan-setup">
22152 <div class="action-card-left">
22153 <div class="action-card-icon">
22154 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
22155 </div>
22156 <div class="action-card-title">Scan Project</div>
22157 <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. All scan history stays accessible for instant revisiting.</p>
22158 <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>
22159 </div>
22160 <div class="action-card-sep"></div>
22161 <div class="action-card-right">
22162 <div class="ac-right-row"><svg viewBox="0 0 24 24"><polyline points="1 4 1 10 7 10"></polyline><path d="M3.51 15a9 9 0 1 0 .49-3.51"></path></svg><span>Re-run last scan</span></div>
22163 <div class="ac-right-row"><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></svg><span>Load from config</span></div>
22164 <div class="ac-right-row"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg><span>Browse history</span></div>
22165 <div class="ac-right-stat" id="acp-scan-stat"></div>
22166 </div>
22167 </a>
22168 <a class="action-card test-metrics card-split" href="/test-metrics">
22169 <div class="action-card-left">
22170 <div class="action-card-icon">
22171 <svg viewBox="0 0 24 24"><polyline points="9 11 12 14 22 4"></polyline><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path></svg>
22172 </div>
22173 <div class="action-card-title">Test Metrics</div>
22174 <p class="action-card-desc">Detect test files and functions across your codebase, measure test-to-code ratios, and view unit test coverage data alongside your SLOC metrics.</p>
22175 <span class="action-card-cta">View test metrics <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>
22176 </div>
22177 <div class="action-card-sep"></div>
22178 <div class="action-card-right">
22179 <div class="ac-right-row"><svg viewBox="0 0 24 24"><polyline points="9 11 12 14 22 4"></polyline><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path></svg><span>Unit test detection</span></div>
22180 <div class="ac-right-row"><svg viewBox="0 0 24 24"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg><span>Assertion counting</span></div>
22181 <div class="ac-right-row"><svg viewBox="0 0 24 24"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg><span>LCOV coverage</span></div>
22182 <div class="ac-right-stat" id="acp-test-stat"></div>
22183 </div>
22184 </a>
22185 </div>
22186 </div>
22187
22188 <div>
22189 <div class="card-section-label">Reports & Insights</div>
22190 <div class="card-section-grid-3">
22191 <a class="action-card view" href="/view-reports">
22192 <div class="action-card-icon">
22193 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
22194 </div>
22195 <div class="action-card-title">View Reports</div>
22196 <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
22197 <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>
22198 </a>
22199 <a class="action-card compare" href="/compare-scans">
22200 <div class="action-card-icon">
22201 <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>
22202 </div>
22203 <div class="action-card-title">Compare Scans</div>
22204 <p class="action-card-desc">Pick any two builds for a side-by-side diff — added, removed, and changed files with exact line-count deltas.</p>
22205 <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>
22206 </a>
22207 <a class="action-card trend" href="/trend-reports">
22208 <div class="action-card-icon">
22209 <svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>
22210 </div>
22211 <div class="action-card-title">Trend Report</div>
22212 <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
22213 <span class="action-card-cta">View trends <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>
22214 </a>
22215 </div>
22216 </div>
22217
22218 <div>
22219 <div class="card-section-label">Developer Tools</div>
22220 <div class="card-section-grid-2">
22221 <a class="action-card git-tools card-split" href="/git-browser">
22222 <div class="action-card-left">
22223 <div class="action-card-icon">
22224 <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>
22225 </div>
22226 <div class="action-card-title">Git Browser</div>
22227 <p class="action-card-desc">Browse branches and commits, scan any ref on demand, and diff two refs side-by-side — all from within the browser, without any local setup.</p>
22228 <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>
22229 </div>
22230 <div class="action-card-sep"></div>
22231 <div class="action-card-right">
22232 <div class="ac-right-row"><svg viewBox="0 0 24 24"><line x1="6" y1="3" x2="6" y2="15"></line><circle cx="18" cy="6" r="3"></circle><circle cx="6" cy="18" r="3"></circle><path d="M18 9a9 9 0 0 1-9 9"></path></svg><span>Branches & tags</span></div>
22233 <div class="ac-right-row"><svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg><span>On-demand scanning</span></div>
22234 <div class="ac-right-row"><svg viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg><span>Side-by-side diff</span></div>
22235 </div>
22236 </a>
22237 <a class="action-card automation card-split" href="/integrations">
22238 <div class="action-card-left">
22239 <div class="action-card-icon">
22240 <svg viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
22241 </div>
22242 <div class="action-card-title">Integrations</div>
22243 <p class="action-card-desc">Connect GitHub, GitLab, or Bitbucket webhooks to trigger scans on every push, or publish results directly to Atlassian Confluence.</p>
22244 <span class="action-card-cta">Set up integrations <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>
22245 </div>
22246 <div class="action-card-sep"></div>
22247 <div class="action-card-right">
22248 <div class="ac-badges-grid">
22249 <span class="ac-badge github" id="acp-gh">GitHub</span>
22250 <span class="ac-badge gitlab" id="acp-gl">GitLab</span>
22251 <span class="ac-badge bitbucket" id="acp-bb">Bitbucket</span>
22252 <span class="ac-badge confluence" id="acp-cf">Confluence</span>
22253 </div>
22254 <div class="ac-right-stat" id="acp-int-stat"></div>
22255 </div>
22256 </a>
22257 </div>
22258 </div>
22259
22260 </div>
22261
22262 {% if server_mode %}
22263 <div class="lan-card server">
22264 <div class="lan-card-header">
22265 <span class="lan-badge">LAN server</span>
22266 Accessible on your network
22267 </div>
22268 {% if let Some(ip) = lan_ip %}
22269 <div class="lan-url-row">
22270 <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
22271 <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
22272 <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>
22273 Copy URL
22274 </button>
22275 </div>
22276 <p class="lan-hint">Share this address with anyone on the same network.{% if has_api_key %} Authentication: enabled.{% else %} Authentication: not configured — all endpoints are open.{% endif %}</p>
22277 {% if has_api_key %}
22278 <div class="lan-auth-row">curl -H "Authorization: Bearer $SLOC_API_KEY" http://{{ ip }}:{{ port }}/healthz</div>
22279 {% endif %}
22280 {% else %}
22281 <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>.{% if has_api_key %} Authentication: enabled.{% else %} Authentication: not configured.{% endif %}</p>
22282 {% endif %}
22283 </div>
22284 {% endif %}
22285
22286 <div class="divider"></div>
22287
22288 <div class="info-strip">
22289 <div class="info-chip">
22290 <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 48 more</div>
22291 <div class="chip-slide">
22292 <div class="info-chip-val">60</div>
22293 <div class="info-chip-label">Languages</div>
22294 </div>
22295 </div>
22296 <div class="info-chip">
22297 <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
22298 <div class="chip-slide">
22299 <div class="info-chip-val">100%</div>
22300 <div class="info-chip-label">Self-contained</div>
22301 </div>
22302 </div>
22303 <div class="info-chip">
22304 <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
22305 <div class="chip-slide">
22306 <div class="info-chip-val">HTML+PDF</div>
22307 <div class="info-chip-label">Exportable reports</div>
22308 </div>
22309 </div>
22310 <div class="info-chip">
22311 <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
22312 <div class="chip-slide">
22313 <div class="info-chip-val">Webhook</div>
22314 <div class="info-chip-label">3 platforms</div>
22315 </div>
22316 </div>
22317 <div class="info-chip">
22318 <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
22319 <div class="chip-slide">
22320 <div class="info-chip-val">IEEE</div>
22321 <div class="info-chip-label">1045-1992</div>
22322 </div>
22323 </div>
22324 </div>
22325
22326 {% if lan_ip.is_none() %}
22327 <div class="lan-local-hint">
22328 <strong>Want teammates on the same network to access this?</strong><br>
22329 Relaunch in server mode: <code>oxide-sloc serve --server</code> or <code>bash scripts/serve-server.sh</code>
22330 </div>
22331 {% endif %}
22332 </div>
22333
22334 <footer class="site-footer">
22335 local code analysis - metrics, history and reports
22336 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
22337 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22338 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22339 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22340 · <a href="/api-docs" rel="noopener">REST API</a>
22341 </footer>
22342
22343 <script nonce="{{ csp_nonce }}">
22344 (function () {
22345 var storageKey = 'oxide-sloc-theme';
22346 var body = document.body;
22347 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
22348 var toggle = document.getElementById('theme-toggle');
22349 if (toggle) toggle.addEventListener('click', function () {
22350 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
22351 body.classList.toggle('dark-theme', next === 'dark');
22352 try { localStorage.setItem(storageKey, next); } catch(e) {}
22353 });
22354 var copyBtn = document.getElementById('lan-copy-btn');
22355 if (copyBtn) copyBtn.addEventListener('click', function() {
22356 var btn = this;
22357 var el = document.getElementById('lan-url-val');
22358 if (!el) return;
22359 var url = el.textContent.trim();
22360 if (navigator.clipboard) {
22361 navigator.clipboard.writeText(url).then(function() {
22362 var orig = btn.innerHTML;
22363 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!';
22364 setTimeout(function() { btn.innerHTML = orig; }, 1800);
22365 });
22366 }
22367 });
22368 (function randomizeWatermarks() {
22369 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22370 if (!wms.length) return;
22371 var placed = [];
22372 function tooClose(top, left) {
22373 for (var i = 0; i < placed.length; i++) {
22374 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
22375 if (dt < 16 && dl < 12) return true;
22376 }
22377 return false;
22378 }
22379 function pick(leftBand) {
22380 for (var attempt = 0; attempt < 50; attempt++) {
22381 var top = Math.random() * 88 + 2;
22382 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22383 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
22384 }
22385 var top = Math.random() * 88 + 2;
22386 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22387 placed.push([top, left]); return [top, left];
22388 }
22389 var half = Math.floor(wms.length / 2);
22390 wms.forEach(function (img, i) {
22391 var pos = pick(i < half);
22392 var size = Math.floor(Math.random() * 100 + 120);
22393 var rot = (Math.random() * 360).toFixed(1);
22394 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
22395 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;
22396 });
22397 })();
22398
22399 (function spawnCodeParticles() {
22400 var container = document.getElementById('code-particles');
22401 if (!container) return;
22402 var snippets = [
22403 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
22404 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
22405 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
22406 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
22407 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
22408 ];
22409 var count = 38;
22410 for (var i = 0; i < count; i++) {
22411 (function(idx) {
22412 var el = document.createElement('span');
22413 el.className = 'code-particle';
22414 var text = snippets[idx % snippets.length];
22415 el.textContent = text;
22416 var left = Math.random() * 94 + 2;
22417 var top = Math.random() * 88 + 6;
22418 var dur = (Math.random() * 10 + 9).toFixed(1);
22419 var delay = (Math.random() * 18).toFixed(1);
22420 var rot = (Math.random() * 26 - 13).toFixed(1);
22421 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
22422 el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
22423 + '--rot:' + rot + 'deg;--op:' + op + ';'
22424 + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
22425 container.appendChild(el);
22426 })(i);
22427 }
22428 })();
22429 (function heroAnimations() {
22430 var sub = document.getElementById('hero-subtitle');
22431 if (sub) {
22432 var full = sub.textContent.trim();
22433 sub.textContent = '';
22434 sub.style.opacity = '1';
22435 var cursor = document.createElement('span');
22436 cursor.className = 'hero-cursor';
22437 sub.appendChild(cursor);
22438 var i = 0;
22439 setTimeout(function() {
22440 var iv = setInterval(function() {
22441 if (i < full.length) {
22442 sub.insertBefore(document.createTextNode(full[i]), cursor);
22443 i++;
22444 } else {
22445 clearInterval(iv);
22446 setTimeout(function() {
22447 cursor.style.transition = 'opacity 1s ease';
22448 cursor.style.opacity = '0';
22449 setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
22450 }, 2400);
22451 }
22452 }, 11);
22453 }, 374);
22454 }
22455 })();
22456 (function logoBob() {
22457 var logo = document.querySelector('.hero-logo');
22458 var shadow = document.querySelector('.hero-logo-shadow');
22459 if (!logo) return;
22460 var cycleStart = null, cycleDur = 3600;
22461 var peakY = -14, peakScale = 1.07, peakRot = 0;
22462 function newCycle() {
22463 cycleDur = 3000 + Math.random() * 1840;
22464 peakY = -(9 + Math.random() * 13.8);
22465 peakScale = 1.04 + Math.random() * 0.081;
22466 peakRot = (Math.random() * 11.5 - 5.75);
22467 }
22468 function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
22469 newCycle();
22470 function frame(ts) {
22471 if (cycleStart === null) cycleStart = ts;
22472 var t = (ts - cycleStart) / cycleDur;
22473 if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
22474 var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
22475 var y = peakY * phase;
22476 var sc = 1 + (peakScale - 1) * phase;
22477 var rot = peakRot * Math.sin(Math.PI * phase);
22478 logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
22479 if (shadow) {
22480 shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
22481 shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
22482 }
22483 requestAnimationFrame(frame);
22484 }
22485 requestAnimationFrame(frame);
22486 })();
22487 (function mouseEffects() {
22488 var heroTitle = document.getElementById('hero-title');
22489 var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
22490 function tick() {
22491 raf = null;
22492 if (heroTitle) {
22493 var r = heroTitle.getBoundingClientRect();
22494 var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
22495 var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
22496 heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
22497 }
22498 }
22499 document.addEventListener('mousemove', function(e) {
22500 mx = e.clientX; my = e.clientY;
22501 if (!raf) raf = requestAnimationFrame(tick);
22502 });
22503 document.addEventListener('mouseleave', function() {
22504 if (heroTitle) {
22505 heroTitle.style.transition = 'transform 0.5s ease';
22506 heroTitle.style.transform = '';
22507 setTimeout(function() { heroTitle.style.transition = ''; }, 500);
22508 }
22509 });
22510 document.querySelectorAll('.action-card').forEach(function(card) {
22511 card.addEventListener('mousemove', function(e) {
22512 var rect = card.getBoundingClientRect();
22513 var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
22514 var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
22515 card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
22516 card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
22517 });
22518 card.addEventListener('mouseleave', function() {
22519 card.style.transition = '';
22520 card.style.transform = '';
22521 });
22522 });
22523 })();
22524 (function chipSlideshow() {
22525 var slides = [
22526 [{v:'60',l:'Languages'},{v:'Rust \u00b7 Go \u00b7 Python',l:'and 57 more'},{v:'C \u00b7 Java \u00b7 TypeScript',l:'Swift \u00b7 Kotlin \u00b7 Zig'}],
22527 [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
22528 [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
22529 [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
22530 [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
22531 ];
22532 var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
22533 var indices = [0,0,0,0,0];
22534 var paused = [false,false,false,false,false];
22535 chips.forEach(function(chip, i) {
22536 chip.addEventListener('mouseenter', function() { paused[i] = true; });
22537 chip.addEventListener('mouseleave', function() { paused[i] = false; });
22538 });
22539 function advance(i) {
22540 if (paused[i]) return;
22541 var chip = chips[i];
22542 var inner = chip.querySelector('.chip-slide');
22543 if (!inner) return;
22544 inner.classList.add('fading');
22545 setTimeout(function() {
22546 indices[i] = (indices[i] + 1) % slides[i].length;
22547 var s = slides[i][indices[i]];
22548 chip.querySelector('.info-chip-val').textContent = s.v;
22549 chip.querySelector('.info-chip-label').textContent = s.l;
22550 inner.classList.remove('fading');
22551 }, 720);
22552 }
22553 setInterval(function() {
22554 chips.forEach(function(chip, i) { advance(i); });
22555 }, 6000);
22556 })();
22557 (function cardLiveData() {
22558 fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
22559 var el = document.getElementById('acp-scan-stat');
22560 if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
22561 }).catch(function(){});
22562 fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
22563 var el = document.getElementById('acp-test-stat');
22564 if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
22565 }).catch(function(){});
22566 fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
22567 var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
22568 var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
22569 if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
22570 if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
22571 if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
22572 var stat = document.getElementById('acp-int-stat');
22573 if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
22574 }).catch(function(){});
22575 fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
22576 if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
22577 }).catch(function(){});
22578 })();
22579 })();
22580 </script>
22581 <script nonce="{{ csp_nonce }}">
22582 (function(){
22583 var S=[{n:'Classic',a:'#b85d33',b:'#7a371b'},{n:'Navy',a:'#283790',b:'#1e1e24'},{n:'Ember',a:'#ce5d3d',b:'#1e1e24'},{n:'Ocean',a:'#1f439b',b:'#1e1e24'},{n:'Royal',a:'#003184',b:'#1e1e24'}];
22584 function ap(s){document.documentElement.style.setProperty('--nav',s.a);document.documentElement.style.setProperty('--nav-2',s.b);try{localStorage.setItem('sloc-ns',JSON.stringify(s));}catch(e){}document.querySelectorAll('.scheme-swatch').forEach(function(x){x.classList.toggle('active',x.dataset.n===s.n);});}
22585 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22586 function init(){
22587 var btn=document.getElementById('settings-btn');if(!btn)return;
22588 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22589 m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
22590 document.body.appendChild(m);
22591 var g=document.getElementById('scheme-grid');
22592 if(g)S.forEach(function(s){var el=document.createElement('button');el.type='button';el.className='scheme-swatch';el.dataset.n=s.n;el.title=s.n;var p=document.createElement('div');p.className='scheme-preview';p.style.background='linear-gradient(135deg,'+s.a+','+s.b+')';var l=document.createElement('span');l.className='scheme-label';l.textContent=s.n;el.appendChild(p);el.appendChild(l);try{var c=JSON.parse(localStorage.getItem('sloc-ns'));if(c&&c.n===s.n)el.classList.add('active');}catch(e){}el.addEventListener('click',function(){ap(s);});g.appendChild(el);});
22593 var cl=document.getElementById('settings-close');
22594 window.tzAbbr=function(z){return{'America/Los_Angeles':'PT','America/Denver':'MT','America/Chicago':'CT','America/New_York':'ET','America/Anchorage':'AT','Pacific/Honolulu':'HT'}[z]||'PT';};window.tzCity=function(z){return{'America/Los_Angeles':'Los Angeles','America/Denver':'Denver','America/Chicago':'Chicago','America/New_York':'New York','America/Anchorage':'Anchorage','Pacific/Honolulu':'Honolulu'}[z]||'';};window.tzOffset=function(z){var r='';try{var p=new Intl.DateTimeFormat('en-US',{timeZone:z,timeZoneName:'longOffset'}).formatToParts(new Date());p.forEach(function(x){if(x.type==='timeZoneName')r=x.value.replace('GMT','UTC');});}catch(e){}return r;};window.tf24=function(){try{return localStorage.getItem('sloc-tf')!=='12';}catch(e){return true;}};window.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';var h24=window.tf24();try{var pts=new Intl.DateTimeFormat('en-US',{timeZone:tz,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:!h24}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});var t=v.hour+':'+v.minute;if(!h24&&v.dayPeriod)t+=' '+v.dayPeriod;return v.year+'-'+v.month+'-'+v.day+' '+t+' '+window.tzAbbr(tz);}catch(e){return'';}};window.enhanceTzOptions=function(sel){if(!sel)return;Array.prototype.forEach.call(sel.options,function(o){var base=o.textContent.split(' - ')[0];var city=window.tzCity(o.value);var off=window.tzOffset(o.value);o.textContent=base+(city?' - '+city:'')+(off?' - '+off:'');});};window.applyTz=function(tz){try{localStorage.setItem('sloc-tz',tz);}catch(e){}document.querySelectorAll('[data-utc-ms]').forEach(function(el){var ms=parseInt(el.getAttribute('data-utc-ms'),10);if(!isNaN(ms))el.textContent=window.fmtTz(ms,tz);});};window.applyTf=function(tf){try{localStorage.setItem('sloc-tf',tf);}catch(e){}var z;try{z=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){z='America/Los_Angeles';}window.applyTz(z);};var tzSel=document.getElementById('tz-select');window.enhanceTzOptions(tzSel);var storedTz;try{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){storedTz='America/Los_Angeles';}if(tzSel){tzSel.value=storedTz;tzSel.addEventListener('change',function(){window.applyTz(this.value);});}window.applyTz(storedTz);(function(){var tzp=document.getElementById('tz-select');if(!tzp||document.getElementById('tf-select')||!tzp.parentNode)return;var tw=document.createElement('div');tw.style.marginTop='10px';var tl=document.createElement('div');tl.className='settings-modal-label';tl.style.marginBottom='8px';tl.textContent='Time format';var tfSel=document.createElement('select');tfSel.className='tz-select';tfSel.id='tf-select';tfSel.innerHTML='<option value="24">24-hour (14:30)</option><option value="12">12-hour (2:30 PM)</option>';tw.appendChild(tl);tw.appendChild(tfSel);tzp.parentNode.appendChild(tw);var storedTf;try{storedTf=localStorage.getItem('sloc-tf')||'24';}catch(e){storedTf='24';}tfSel.value=storedTf;tfSel.addEventListener('change',function(){window.applyTf(this.value);});})();
22595 btn.addEventListener('click',function(e){e.stopPropagation();var r=btn.getBoundingClientRect();m.style.top=(r.bottom+6)+'px';m.style.right=(window.innerWidth-r.right)+'px';m.classList.toggle('open');});
22596 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22597 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22598 }
22599 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
22600 }());
22601 </script>
22602 <script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl&&lbl.textContent==='Server')lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} \u2014 Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
22603</body>
22604</html>
22605"##,
22606 ext = "html"
22607)]
22608struct SplashTemplate {
22609 csp_nonce: String,
22610 server_mode: bool,
22611 lan_ip: Option<String>,
22612 port: u16,
22613 version: &'static str,
22614 has_api_key: bool,
22615}
22616
22617#[derive(Template)]
22620#[template(
22621 source = r##"
22622<!doctype html>
22623<html lang="en">
22624<head>
22625 <meta charset="utf-8">
22626 <meta name="viewport" content="width=device-width, initial-scale=1">
22627 <title>OxideSLOC — Start a Scan</title>
22628 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
22629 <style nonce="{{ csp_nonce }}">
22630 :root {
22631 --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
22632 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
22633 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
22634 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
22635 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
22636 }
22637 body.dark-theme {
22638 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
22639 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
22640 }
22641 *{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);} body{display:flex;flex-direction:column;}
22642 .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);}
22643 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
22644 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
22645 .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));}
22646 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
22647 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
22648 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
22649 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
22650 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
22651 @media (max-width: 1150px) { .nav-right { gap: 4px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 8px; font-size: 11px; min-height: 34px; } .brand-subtitle { display: none; } .server-online-pill { width: 34px; padding: 0; justify-content: center; font-size: 0; gap: 0; min-height: 34px; } }
22652 .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;white-space:nowrap;text-decoration:none;}
22653 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
22654 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
22655 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
22656 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
22657 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
22658 .settings-modal{position:fixed;z-index:9999;background:var(--surface-2);border:1px solid var(--line-strong);border-radius:14px;box-shadow:0 12px 36px rgba(0,0,0,0.22);min-width:260px;max-width:320px;opacity:0;pointer-events:none;transform:translateY(-8px) scale(0.97);transition:opacity 0.18s ease,transform 0.18s ease;overflow:hidden;}
22659 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
22660 .settings-modal-header{display:flex;align-items:center;justify-content:space-between;padding:14px 16px 10px;border-bottom:1px solid var(--line);font-size:13px;font-weight:800;color:var(--text);}
22661 .settings-close{background:none;border:none;cursor:pointer;width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--muted);border-radius:6px;padding:0;}
22662 .settings-close:hover{color:var(--text);background:var(--surface-2);}
22663 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
22664 .settings-modal-body{padding:14px 16px 16px;}
22665 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
22666 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
22667 .scheme-swatch{display:flex;flex-direction:column;align-items:center;gap:5px;background:none;border:1.5px solid var(--line);border-radius:10px;cursor:pointer;padding:7px 4px 6px;transition:border-color 0.15s ease,transform 0.12s ease;}
22668 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
22669 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
22670 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
22671 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
22672 .tz-select{width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2);color:var(--text);font-size:12px;font-weight:600;cursor:pointer;outline:none;box-sizing:border-box;}
22673 .tz-select:focus{border-color:var(--oxide);}
22674 .page{max-width:1104px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
22675 .page-header{text-align:center;margin-bottom:16px;}
22676 .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
22677 .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
22678 /* Cards */
22679 .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
22680 .option-card-wrap{position:relative;}
22681 .option-card{background:var(--surface);border:1.5px solid var(--line-strong);border-radius:var(--radius);padding:20px 24px;box-shadow:var(--shadow);transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;position:relative;z-index:1;display:flex;align-items:center;gap:20px;animation:cardRise 0.7s ease both;}
22682 .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
22683 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
22684 @media(prefers-reduced-motion:reduce){.option-card{animation:none;}}
22685 .option-card-wrap:nth-child(1) .option-card{animation-delay:0.1s;} .option-card-wrap:nth-child(2) .option-card{animation-delay:0.2s;} .option-card-wrap:nth-child(3) .option-card{animation-delay:0.3s;}
22686 .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
22687 .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
22688 #recent-card{flex-direction:column;align-items:stretch;gap:0;}
22689 .card-top-row{display:flex;align-items:center;gap:20px;}
22690 /* Two-column layout inside each card */
22691 .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
22692 .card-left{display:flex;align-items:flex-start;min-width:0;}
22693 .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
22694 .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
22695 .option-icon.new-scan{background:linear-gradient(135deg,#e07b3a,#b85028);box-shadow:0 10px 30px rgba(224,123,58,0.55),0 4px 10px rgba(0,0,0,0.22);}
22696 .option-icon.load-config{background:linear-gradient(135deg,#3b82f6,#1d4ed8);box-shadow:0 10px 30px rgba(59,130,246,0.55),0 4px 10px rgba(0,0,0,0.22);}
22697 .option-icon.rescan{background:linear-gradient(135deg,#8b5cf6,#6d28d9);box-shadow:0 10px 30px rgba(139,92,246,0.55),0 4px 10px rgba(0,0,0,0.22);}
22698 .card-text{min-width:0;}
22699 .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
22700 .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
22701 .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
22702 .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
22703 .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
22704 /* Right CTA column */
22705 .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
22706 .btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:8px 16px;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;}
22707 /* Re-scan count badge */
22708 .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
22709 .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
22710 .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
22711 body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
22712 .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
22713 .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
22714 .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
22715 body.dark-theme .btn-secondary{color:var(--oxide);}
22716 .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
22717 .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
22718 /* File input overlay — must be full-width so it aligns with other card-right buttons */
22719 .file-input-wrap{position:relative;width:100%;}
22720 .file-input-wrap .btn{width:100%;}
22721 .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
22722 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22723 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
22724 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22725 .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;}
22726 @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));}}
22727 /* Recent list (card 3 — full-width section below header) */
22728 .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
22729 .recent-list{display:flex;flex-direction:column;gap:8px;}
22730 .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;}
22731 .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
22732 .recent-item-info{flex:1;min-width:0;}
22733 .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
22734 .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
22735 .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
22736 .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
22737 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22738 .site-footer a{color:var(--muted);}
22739 @media(max-width:680px){
22740 .card-body{grid-template-columns:1fr;}
22741 .card-right{flex-direction:row;flex-wrap:wrap;}
22742 .btn{flex:1;}
22743 }
22744 .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;white-space:nowrap;text-decoration:none;}.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;}
22745 .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;}
22746 .server-status-wrap{position:relative;display:inline-flex;}.server-online-pill{cursor:default;}.server-status-tip{visibility:hidden;opacity:0;pointer-events: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);border:1px solid rgba(255,255,255,0.10);transition:opacity 0.15s ease;}.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{visibility:visible;opacity:1;pointer-events:auto;}
22747 </style>
22748</head>
22749<body>
22750 <div class="background-watermarks" aria-hidden="true">
22751 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22752 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22753 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22754 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22755 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22756 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22757 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22758 </div>
22759 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22760 <div class="top-nav">
22761 <div class="top-nav-inner">
22762 <a class="brand" href="/">
22763 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
22764 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
22765 </a>
22766 <div class="nav-right">
22767 <a class="nav-pill" href="/">Home</a>
22768 <div class="nav-dropdown">
22769 <a href="/view-reports" class="nav-dropdown-btn">View Reports <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></a>
22770 <div class="nav-dropdown-menu">
22771 <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
22772 </div>
22773 </div>
22774 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
22775 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22776 <div class="nav-dropdown">
22777 <a href="/git-browser" class="nav-dropdown-btn">Git Browser <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></a>
22778 <div class="nav-dropdown-menu">
22779 <a href="/integrations"><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>Integrations</a>
22780 </div>
22781 </div>
22782 <div class="server-status-wrap" id="server-status-wrap">
22783 <div class="nav-pill server-online-pill" id="server-status-pill">
22784 <span class="status-dot" id="status-dot"></span>
22785 <span id="server-status-label">Server</span>
22786 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22787 </div>
22788 <div class="server-status-tip">
22789 OxideSLOC is running — accessible on your network.
22790 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22791 </div>
22792 </div>
22793 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22794 <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
22795 </button>
22796 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
22797 <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>
22798 <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>
22799 </button>
22800 </div>
22801 </div>
22802 </div>
22803
22804 <div class="page">
22805 <div class="page-header">
22806 <h1>How would you like to scan?</h1>
22807 <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
22808 </div>
22809
22810 <div class="option-grid">
22811
22812 <!-- Option 1: New scan -->
22813 <div class="option-card-wrap">
22814 <div class="option-card">
22815 <div class="option-icon new-scan">
22816 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
22817 </div>
22818 <div class="card-body">
22819 <div class="card-left">
22820 <div class="card-text">
22821 <div class="option-title">Start a new scan</div>
22822 <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>
22823 <ul class="feature-list">
22824 <li>Live project scope preview before you run</li>
22825 <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
22826 <li>HTML, PDF, and JSON output — your choice</li>
22827 </ul>
22828 </div>
22829 </div>
22830 <div class="card-right">
22831 <a class="btn btn-primary" href="/scan">
22832 Configure & scan
22833 <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
22834 </a>
22835 <p class="card-tip">Full 4-step setup · all options</p>
22836 </div>
22837 </div>
22838 </div>
22839 </div>
22840
22841 <!-- Option 2: Load from config file -->
22842 <div class="option-card-wrap">
22843 <div class="option-card">
22844 <div class="option-icon load-config">
22845 <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>
22846 </div>
22847 <div class="card-body">
22848 <div class="card-left">
22849 <div class="card-text">
22850 <div class="option-title">Load a saved config</div>
22851 <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>
22852 <ul class="feature-list">
22853 <li>All 15 settings restored from the file</li>
22854 <li>Fully editable — change path or output dir</li>
22855 <li>Works with any scan-config.json</li>
22856 </ul>
22857 </div>
22858 </div>
22859 <div class="card-right">
22860 <div class="file-input-wrap">
22861 <button class="btn btn-secondary" id="load-config-btn" type="button">
22862 <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>
22863 Choose config file
22864 </button>
22865 <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
22866 </div>
22867 <p class="card-tip" id="config-file-name">Exported after every scan</p>
22868 </div>
22869 </div>
22870 </div>
22871 </div>
22872
22873 <!-- Option 3: Re-scan recent project -->
22874 <div class="option-card-wrap">
22875 <div class="option-card" id="recent-card">
22876 <div class="card-top-row">
22877 <div class="option-icon rescan">
22878 <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>
22879 </div>
22880 <div class="card-body">
22881 <div class="card-left">
22882 <div class="card-text">
22883 <div class="option-title">Re-scan a recent project</div>
22884 <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>
22885 <ul class="feature-list">
22886 <li>All 15+ settings restored from the saved config</li>
22887 <li>Path and output dir are editable before running</li>
22888 <li>Only scans with a saved config appear here</li>
22889 </ul>
22890 </div>
22891 </div>
22892 <div class="card-right">
22893 <div class="rescan-count-box">
22894 <div class="rescan-count-num" id="rescan-count-num">—</div>
22895 <div class="rescan-count-label">saved configs</div>
22896 </div>
22897 <a class="btn btn-secondary" href="/view-reports">
22898 <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></svg>
22899 View all runs
22900 </a>
22901 <p class="card-tip">Opens run history</p>
22902 </div>
22903 </div>
22904 </div>
22905 <div class="section-divider"></div>
22906 <div class="recent-list" id="recent-list">
22907 <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
22908 </div>
22909 </div>
22910 </div>
22911
22912 </div>
22913 </div>
22914
22915 <footer class="site-footer">
22916 local code analysis - metrics, history and reports
22917 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
22918 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22919 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22920 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22921 · <a href="/api-docs" rel="noopener">REST API</a>
22922 </footer>
22923
22924 <script nonce="{{ csp_nonce }}">
22925 (function () {
22926 var storageKey = 'oxide-sloc-theme';
22927 var body = document.body;
22928 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
22929 var toggle = document.getElementById('theme-toggle');
22930 if (toggle) toggle.addEventListener('click', function () {
22931 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
22932 body.classList.toggle('dark-theme', next === 'dark');
22933 try { localStorage.setItem(storageKey, next); } catch(e) {}
22934 });
22935
22936 (function randomizeWatermarks() {
22937 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22938 if (!wms.length) return;
22939 var placed = [];
22940 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; }
22941 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]; }
22942 var half = Math.floor(wms.length / 2);
22943 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; });
22944 })();
22945 (function spawnCodeParticles() {
22946 var container = document.getElementById('code-particles');
22947 if (!container) return;
22948 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'];
22949 var count = 38;
22950 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); }
22951 })();
22952 // Recent scans data injected from server
22953 var recentScans = {{ recent_scans_json|safe }};
22954
22955 function configToParams(cfg) {
22956 var p = new URLSearchParams();
22957 p.set('prefilled', '1');
22958 if (cfg.path) p.set('path', cfg.path);
22959 if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
22960 if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
22961 if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
22962 p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
22963 p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
22964 p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
22965 p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
22966 p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
22967 if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
22968 p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
22969 if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
22970 if (cfg.report_title) p.set('report_title', cfg.report_title);
22971 p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
22972 if (cfg.generate_pdf) p.set('generate_pdf', 'on');
22973 if (cfg.continuation_line_policy) p.set('continuation_line_policy', cfg.continuation_line_policy);
22974 if (cfg.blank_in_block_comment_policy) p.set('blank_in_block_comment_policy', cfg.blank_in_block_comment_policy);
22975 p.set('count_compiler_directives', cfg.count_compiler_directives === false ? 'disabled' : 'enabled');
22976 p.set('style_analysis_enabled', cfg.style_analysis_enabled === false ? 'disabled' : 'enabled');
22977 if (cfg.style_col_threshold) p.set('style_col_threshold', String(cfg.style_col_threshold));
22978 if (cfg.style_score_threshold) p.set('style_score_threshold', String(cfg.style_score_threshold));
22979 if (cfg.style_lang_scope) p.set('style_lang_scope', cfg.style_lang_scope);
22980 if (cfg.coverage_file) p.set('coverage_file', cfg.coverage_file);
22981 if (cfg.cocomo_mode) p.set('cocomo_mode', cfg.cocomo_mode);
22982 if (cfg.complexity_alert) p.set('complexity_alert', String(cfg.complexity_alert));
22983 if (cfg.activity_window !== undefined && cfg.activity_window !== null) p.set('activity_window', String(cfg.activity_window));
22984 if (cfg.exclude_duplicates) p.set('exclude_duplicates', 'enabled');
22985 return p;
22986 }
22987
22988 // Build recent scan list (capped at 3 visible entries)
22989 var list = document.getElementById('recent-list');
22990 var noNote = document.getElementById('no-recent-note');
22991 var hasAny = false;
22992 var MAX_RECENT = 3;
22993 if (Array.isArray(recentScans)) {
22994 var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
22995 var shown = 0;
22996 validEntries.forEach(function (entry) {
22997 if (shown >= MAX_RECENT) return;
22998 shown++;
22999 hasAny = true;
23000 var item = document.createElement('div');
23001 item.className = 'recent-item';
23002 item.title = 'Restore all settings and open wizard';
23003 item.innerHTML =
23004 '<div class="recent-item-info">' +
23005 '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
23006 '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' \u00b7 ' + escHtml(entry.timestamp || '') + '</div>' +
23007 '</div>' +
23008 '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
23009 item.addEventListener('click', function () {
23010 var params = configToParams(entry.config);
23011 window.location.href = '/scan?' + params.toString();
23012 });
23013 list.appendChild(item);
23014 });
23015 if (validEntries.length > MAX_RECENT) {
23016 var moreEl = document.createElement('div');
23017 moreEl.className = 'recent-more-link';
23018 moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more — <a href="/view-reports">view all runs</a>';
23019 list.appendChild(moreEl);
23020 }
23021 }
23022 if (hasAny && noNote) noNote.style.display = 'none';
23023 // Update count badge
23024 var countEl = document.getElementById('rescan-count-num');
23025 if (countEl) {
23026 var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
23027 countEl.textContent = total > 0 ? total : '0';
23028 }
23029
23030 // Config file loader
23031 var fileInput = document.getElementById('config-file-input');
23032 var fileName = document.getElementById('config-file-name');
23033 var loadBtn = document.getElementById('load-config-btn');
23034 // Wire the visible button to open the hidden file picker.
23035 if (loadBtn && fileInput) {
23036 loadBtn.addEventListener('click', function () { fileInput.click(); });
23037 }
23038 if (fileInput) {
23039 fileInput.addEventListener('change', function () {
23040 var file = fileInput.files && fileInput.files[0];
23041 if (!file) return;
23042 if (fileName) fileName.textContent = '\u2713 ' + file.name;
23043 var reader = new FileReader();
23044 reader.onload = function (e) {
23045 try {
23046 var cfg = JSON.parse(e.target.result);
23047 if (!cfg || typeof cfg !== 'object') { alert('Invalid config file \u2014 expected a JSON object.'); return; }
23048 var params = configToParams(cfg);
23049 window.location.href = '/scan?' + params.toString();
23050 } catch (err) {
23051 alert('Could not parse config file: ' + err.message);
23052 }
23053 };
23054 reader.readAsText(file);
23055 });
23056 }
23057
23058 function escHtml(s) {
23059 return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
23060 }
23061 })();
23062 </script>
23063 <script nonce="{{ csp_nonce }}">
23064 (function(){
23065 var S=[{n:'Classic',a:'#b85d33',b:'#7a371b'},{n:'Navy',a:'#283790',b:'#1e1e24'},{n:'Ember',a:'#ce5d3d',b:'#1e1e24'},{n:'Ocean',a:'#1f439b',b:'#1e1e24'},{n:'Royal',a:'#003184',b:'#1e1e24'}];
23066 function ap(s){document.documentElement.style.setProperty('--nav',s.a);document.documentElement.style.setProperty('--nav-2',s.b);try{localStorage.setItem('sloc-ns',JSON.stringify(s));}catch(e){}document.querySelectorAll('.scheme-swatch').forEach(function(x){x.classList.toggle('active',x.dataset.n===s.n);});}
23067 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
23068 function init(){
23069 var btn=document.getElementById('settings-btn');if(!btn)return;
23070 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
23071 m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
23072 document.body.appendChild(m);
23073 var g=document.getElementById('scheme-grid');
23074 if(g)S.forEach(function(s){var el=document.createElement('button');el.type='button';el.className='scheme-swatch';el.dataset.n=s.n;el.title=s.n;var p=document.createElement('div');p.className='scheme-preview';p.style.background='linear-gradient(135deg,'+s.a+','+s.b+')';var l=document.createElement('span');l.className='scheme-label';l.textContent=s.n;el.appendChild(p);el.appendChild(l);try{var c=JSON.parse(localStorage.getItem('sloc-ns'));if(c&&c.n===s.n)el.classList.add('active');}catch(e){}el.addEventListener('click',function(){ap(s);});g.appendChild(el);});
23075 var cl=document.getElementById('settings-close');
23076 window.tzAbbr=function(z){return{'America/Los_Angeles':'PT','America/Denver':'MT','America/Chicago':'CT','America/New_York':'ET','America/Anchorage':'AT','Pacific/Honolulu':'HT'}[z]||'PT';};window.tzCity=function(z){return{'America/Los_Angeles':'Los Angeles','America/Denver':'Denver','America/Chicago':'Chicago','America/New_York':'New York','America/Anchorage':'Anchorage','Pacific/Honolulu':'Honolulu'}[z]||'';};window.tzOffset=function(z){var r='';try{var p=new Intl.DateTimeFormat('en-US',{timeZone:z,timeZoneName:'longOffset'}).formatToParts(new Date());p.forEach(function(x){if(x.type==='timeZoneName')r=x.value.replace('GMT','UTC');});}catch(e){}return r;};window.tf24=function(){try{return localStorage.getItem('sloc-tf')!=='12';}catch(e){return true;}};window.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';var h24=window.tf24();try{var pts=new Intl.DateTimeFormat('en-US',{timeZone:tz,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:!h24}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});var t=v.hour+':'+v.minute;if(!h24&&v.dayPeriod)t+=' '+v.dayPeriod;return v.year+'-'+v.month+'-'+v.day+' '+t+' '+window.tzAbbr(tz);}catch(e){return'';}};window.enhanceTzOptions=function(sel){if(!sel)return;Array.prototype.forEach.call(sel.options,function(o){var base=o.textContent.split(' - ')[0];var city=window.tzCity(o.value);var off=window.tzOffset(o.value);o.textContent=base+(city?' - '+city:'')+(off?' - '+off:'');});};window.applyTz=function(tz){try{localStorage.setItem('sloc-tz',tz);}catch(e){}document.querySelectorAll('[data-utc-ms]').forEach(function(el){var ms=parseInt(el.getAttribute('data-utc-ms'),10);if(!isNaN(ms))el.textContent=window.fmtTz(ms,tz);});};window.applyTf=function(tf){try{localStorage.setItem('sloc-tf',tf);}catch(e){}var z;try{z=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){z='America/Los_Angeles';}window.applyTz(z);};var tzSel=document.getElementById('tz-select');window.enhanceTzOptions(tzSel);var storedTz;try{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){storedTz='America/Los_Angeles';}if(tzSel){tzSel.value=storedTz;tzSel.addEventListener('change',function(){window.applyTz(this.value);});}window.applyTz(storedTz);(function(){var tzp=document.getElementById('tz-select');if(!tzp||document.getElementById('tf-select')||!tzp.parentNode)return;var tw=document.createElement('div');tw.style.marginTop='10px';var tl=document.createElement('div');tl.className='settings-modal-label';tl.style.marginBottom='8px';tl.textContent='Time format';var tfSel=document.createElement('select');tfSel.className='tz-select';tfSel.id='tf-select';tfSel.innerHTML='<option value="24">24-hour (14:30)</option><option value="12">12-hour (2:30 PM)</option>';tw.appendChild(tl);tw.appendChild(tfSel);tzp.parentNode.appendChild(tw);var storedTf;try{storedTf=localStorage.getItem('sloc-tf')||'24';}catch(e){storedTf='24';}tfSel.value=storedTf;tfSel.addEventListener('change',function(){window.applyTf(this.value);});})();
23077 btn.addEventListener('click',function(e){e.stopPropagation();var r=btn.getBoundingClientRect();m.style.top=(r.bottom+6)+'px';m.style.right=(window.innerWidth-r.right)+'px';m.classList.toggle('open');});
23078 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
23079 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
23080 }
23081 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
23082 }());
23083 </script>
23084 <script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';
23085 if(location.protocol==='file:'){if(lbl)lbl.textContent='Offline';if(dot){dot.style.background='#888';dot.style.boxShadow='none';}if(pingEl)pingEl.textContent='';if(fm)fm.textContent='oxide-sloc v{{ version }} \u2014 Saved Report';var td=document.querySelector('.server-status-tip');if(td)td.textContent='Saved HTML report \u2014 server not connected.';return;}
23086 if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
23087</body>
23088</html>
23089"##,
23090 ext = "html"
23091)]
23092struct ScanSetupTemplate {
23093 version: &'static str,
23094 recent_scans_json: String,
23095 csp_nonce: String,
23096}
23097
23098#[derive(Template)]
23099#[template(
23100 source = r##"
23101<!doctype html>
23102<html lang="en">
23103<head>
23104 <meta charset="utf-8">
23105 <meta name="viewport" content="width=device-width, initial-scale=1">
23106 <title>OxideSLOC | {{ report_title }} | Report</title>
23107 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
23108 <style nonce="{{ csp_nonce }}">
23109 :root {
23110 --radius: 18px;
23111 --bg: #f5efe8;
23112 --surface: rgba(255,255,255,0.82);
23113 --surface-2: #fbf7f2;
23114 --surface-3: #efe6dc;
23115 --line: #e6d0bf;
23116 --line-strong: #dcb89f;
23117 --text: #43342d;
23118 --muted: #7b675b;
23119 --muted-2: #a08777;
23120 --nav: #b85d33;
23121 --nav-2: #7a371b;
23122 --accent: #6f9bff;
23123 --accent-2: #4a78ee;
23124 --oxide: #d37a4c;
23125 --oxide-2: #b35428;
23126 --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
23127 --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
23128 --success-bg: #e8f5ed;
23129 --success-text: #1a8f47;
23130 --info-bg: #eef3ff;
23131 --info-text: #4467d8;
23132 }
23133
23134 body.dark-theme {
23135 --bg: #1b1511;
23136 --surface: #261c17;
23137 --surface-2: #2d221d;
23138 --surface-3: #372922;
23139 --line: #524238;
23140 --line-strong: #6c5649;
23141 --text: #f5ece6;
23142 --muted: #c7b7aa;
23143 --muted-2: #aa9485;
23144 --nav: #b85d33;
23145 --nav-2: #7a371b;
23146 --accent: #6f9bff;
23147 --accent-2: #4a78ee;
23148 --oxide: #d37a4c;
23149 --oxide-2: #b35428;
23150 --shadow: 0 18px 42px rgba(0,0,0,0.28);
23151 --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
23152 --success-bg: #163927;
23153 --success-text: #8fe2a8;
23154 --info-bg: #1c2847;
23155 --info-text: #a9c1ff;
23156 }
23157
23158 * { box-sizing: border-box; }
23159 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); }
23160 body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
23161 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
23162 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
23163 .top-nav, .page { position: relative; z-index: 2; }
23164 .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); }
23165 .top-nav-inner { max-width: 1720px; margin: 0 auto; padding: 4px 24px; min-height: 56px; display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 18px; }
23166 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
23167 .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)); }
23168 .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; }
23169 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
23170 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
23171 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
23172 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
23173 .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; }
23174 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
23175 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
23176 .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
23177 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
23178 @media (max-width: 1150px) { .nav-status { gap: 4px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 8px; font-size: 11px; min-height: 34px; } .brand-subtitle { display: none; } .server-online-pill { width: 34px; padding: 0; justify-content: center; font-size: 0; gap: 0; min-height: 34px; } }
23179 .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); white-space: nowrap; text-decoration: none; }
23180 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
23181 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
23182 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
23183 .theme-toggle .icon-sun { display:none; }
23184 body.dark-theme .theme-toggle .icon-sun { display:block; }
23185 body.dark-theme .theme-toggle .icon-moon { display:none; }
23186 .settings-modal{position:fixed;z-index:9999;background:var(--surface-2);border:1px solid var(--line-strong);border-radius:14px;box-shadow:0 12px 36px rgba(0,0,0,0.22);min-width:260px;max-width:320px;opacity:0;pointer-events:none;transform:translateY(-8px) scale(0.97);transition:opacity 0.18s ease,transform 0.18s ease;overflow:hidden;}
23187 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
23188 .settings-modal-header{display:flex;align-items:center;justify-content:space-between;padding:14px 16px 10px;border-bottom:1px solid var(--line);font-size:13px;font-weight:800;color:var(--text);}
23189 .settings-close{background:none;border:none;cursor:pointer;width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--muted);border-radius:6px;padding:0;}
23190 .settings-close:hover{color:var(--text);background:var(--surface-2);}
23191 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
23192 .settings-modal-body{padding:14px 16px 16px;}
23193 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
23194 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
23195 .scheme-swatch{display:flex;flex-direction:column;align-items:center;gap:5px;background:none;border:1.5px solid var(--line);border-radius:10px;cursor:pointer;padding:7px 4px 6px;transition:border-color 0.15s ease,transform 0.12s ease;}
23196 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
23197 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
23198 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
23199 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
23200 .tz-select{width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2);color:var(--text);font-size:12px;font-weight:600;cursor:pointer;outline:none;box-sizing:border-box;}
23201 .tz-select:focus{border-color:var(--oxide);}
23202 .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; }
23203 .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;}
23204 .page { width: 100%; max-width: 1720px; margin: 0 auto; padding: 32px 24px 36px; }
23205 .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
23206 .hero, .panel { padding: 22px; }
23207 .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
23208 .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
23209 .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
23210 .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
23211 .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; }
23212 .compare-banner-body { display:flex; flex-direction:column; gap: 10px; }
23213 .compare-banner-top { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
23214 .compare-banner-actions { display:flex; align-items:center; justify-content:space-between; gap:8px; flex-wrap:wrap; border-top: 1px solid rgba(100,130,220,0.15); padding-top: 10px; }
23215 .compare-banner-actions-left { display:flex; gap:8px; flex-wrap:wrap; }
23216 .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
23217 .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
23218 .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
23219 .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
23220 .delta-cards-inline { display:grid; grid-template-columns:repeat(7,1fr); gap:8px; flex:1 1 auto; }
23221 .delta-card-inline { background:var(--surface); border:1px solid var(--line); border-radius:8px; padding:8px 16px; text-align:center; position:relative; cursor:default; transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1); }
23222 .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
23223 .delta-card-val { font-size:16px; font-weight:800; }
23224 .delta-card-val.pos { color:#1e7e34; }
23225 .delta-card-val.neg { color:var(--neg); }
23226 .delta-card-val.mod { color:#b35428; }
23227 .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
23228 .delta-card-tip { position:absolute; top:calc(100% + 8px); left:50%; transform:translateX(-50%) translateY(-7px); background:var(--text); color:var(--bg); padding:6px 11px; border-radius:8px; font-size:11px; white-space:nowrap; pointer-events:none; opacity:0; transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1); z-index:200; }
23229 .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
23230 .delta-card-inline:hover .delta-card-tip { opacity:1; transform:translateX(-50%) translateY(0); }
23231 .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
23232 .compare-ts { font-size:13px; color:var(--muted); }
23233 .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
23234 .compare-arrow { color: var(--muted); }
23235 .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
23236 .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; }
23237 .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
23238 .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
23239 .run-mgmt-strip { display:flex; flex-wrap:wrap; gap:14px; align-items:stretch; margin-top:18px; }
23240 .run-mgmt-card { flex:1; min-width:220px; padding:12px 16px; border-radius:14px; border:1px solid var(--line); background:var(--surface-2); display:flex; flex-direction:column; align-items:center; gap:6px; text-align:center; }
23241 .run-mgmt-card h3 { margin:0 0 4px; font-size:14px; font-weight:800; }
23242 .run-mgmt-card .action-buttons { justify-content:center; }
23243 .run-mgmt-card .action-empty-note { font-size:11px; color:var(--muted); margin:0; text-align:center; }
23244 body.dark-theme .run-mgmt-card { background:var(--surface-2); border-color:var(--line); }
23245 .button, .copy-button {
23246 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;
23247 }
23248 .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
23249 @keyframes spin { to { transform: rotate(360deg); } }
23250 .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
23251 .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
23252 .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
23253 .path-item strong { display: block; margin-bottom: 6px; }
23254 .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
23255 .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
23256 .path-subitem { flex: 1; }
23257 .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); }
23258 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); }
23259 .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
23260 table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
23261 th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
23262 .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
23263 th { color: var(--muted); font-weight: 700; }
23264 tr:last-child td { border-bottom: none; }
23265 #subm-tbl col:nth-child(1){width:15%;}
23266 #subm-tbl col:nth-child(2){width:31%;}
23267 #subm-tbl col:nth-child(3){width:9%;}
23268 #subm-tbl col:nth-child(4){width:9%;}
23269 #subm-tbl col:nth-child(5){width:9%;}
23270 #subm-tbl col:nth-child(6){width:9%;}
23271 #subm-tbl col:nth-child(7){width:9%;}
23272 #subm-tbl col:nth-child(8){width:9%;}
23273 .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
23274 iframe { width: 100%; min-height: 1000px; border: none; background: white; }
23275 .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
23276 .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
23277 .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
23278 .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
23279 .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; }
23280 .soft-chip.success { gap:5px; padding:0 10px 0 8px; min-height:22px; background:rgba(26,143,71,0.06); color:var(--muted); border:1px solid rgba(26,143,71,0.18); font-size:11px; font-weight:600; letter-spacing:0.03em; }
23281 .soft-chip.success svg { flex:0 0 auto; opacity:0.75; }
23282 body.dark-theme .soft-chip.success { background:rgba(143,226,168,0.07); border-color:rgba(143,226,168,0.18); }
23283 .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
23284 .muted { color: var(--muted); }
23285 /* Run-ID chip row (mirrors HTML report) */
23286 .run-id-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:14px; }
23287 @media(max-width:960px) { .run-id-row { grid-template-columns:1fr 1fr; } }
23288 @media(max-width:560px) { .run-id-row { grid-template-columns:1fr; } }
23289 .run-id-chip { display:flex; flex-direction:column; gap:5px; padding:12px 14px; border-radius:10px; background:var(--surface-2); border:1px solid var(--line); border-left:3px solid var(--accent); color:var(--text); position:relative; cursor:default; transition:transform 0.18s ease,box-shadow 0.18s ease; min-width:0; }
23290 .run-id-chip[data-copy] { cursor:pointer; }
23291 a.run-id-chip { text-decoration:none; cursor:pointer; }
23292 .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
23293 .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
23294 .run-id-chip-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.1em; color:var(--accent); display:flex; align-items:center; gap:4px; }
23295 .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
23296 .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
23297 .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
23298 .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
23299 a.commit-link-value { color:inherit; text-decoration:none; }
23300 a.commit-link-value:hover { color:var(--accent); text-decoration:underline; }
23301 .chip-tooltip { position:absolute; top:calc(100% + 8px); left:50%; transform:translateX(-50%) translateY(-7px); background:var(--text); color:var(--bg); padding:6px 11px; border-radius:8px; font-size:11px; font-weight:500; white-space:nowrap; pointer-events:none; opacity:0; transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1); z-index:200; box-shadow:0 4px 16px rgba(0,0,0,0.25); line-height:1.4; }
23302 .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
23303 .run-id-chip:hover .chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
23304 .chip-label-icon { display:inline-block; vertical-align:middle; opacity:0.8; flex:0 0 auto; }
23305 .run-id-short-badge { font-family:ui-monospace,monospace; font-size:13px; font-weight:700; color:var(--muted); background:var(--surface-2); border:1px solid var(--line); border-radius:6px; padding:2px 8px; letter-spacing:0.04em; white-space:nowrap; align-self:center; }
23306 body.dark-theme .run-id-short-badge { color:var(--muted-2); }
23307 @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
23308 .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
23309 /* Meta chips row */
23310 .meta { display:flex; flex-wrap:wrap; align-items:center; gap:0; margin:14px 0 0; padding:10px 0; border-top:1px solid var(--line); border-bottom:1px solid var(--line); width:100%; }
23311 .meta-chip { flex:1; display:inline-flex; align-items:center; justify-content:center; gap:5px; padding:0 10px; font-size:13px; font-weight:500; color:var(--muted); border-right:1px solid var(--line); line-height:1.8; }
23312 .meta-chip:last-child { border-right:none; }
23313 .meta-chip b { color:var(--text); font-weight:700; }
23314 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
23315 .site-footer a{color:var(--muted);}
23316 .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; }
23317 .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
23318 .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; }
23319 .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
23320 /* Stat chips (matches HTML report) */
23321 .summary-strip { display:grid; grid-template-columns:repeat(8,1fr); gap:10px; margin-top:18px; }
23322 @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
23323 /* Hero stat strip: uniform grid where every card is the same width and the
23324 columns line up across both rows. JS sets the column count to ceil(n/2) so
23325 the cards always occupy exactly two rows; when the count is odd the last
23326 card spans two columns to fill the trailing cell with no empty gap. */
23327 .summary-strip-hero { align-items:stretch; }
23328 .stat-chip { background:var(--surface); border:1px solid var(--line); border-radius:12px; padding:14px 16px; position:relative; cursor:default; transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1); overflow:visible; }
23329 .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
23330 .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
23331 .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
23332 .stat-chip-exact { position:absolute; bottom:6px; right:10px; font-size:12px; font-weight:600; color:var(--muted); font-variant-numeric:tabular-nums; line-height:1; }
23333 .stat-chip-tip { position:absolute; top:calc(100% + 10px); left:50%; transform:translateX(-50%) translateY(-7px); background:var(--text); color:var(--bg); padding:10px 14px; border-radius:8px; font-size:12px; line-height:1.55; white-space:normal; max-width:420px; min-width:200px; text-align:left; pointer-events:none; opacity:0; transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1); z-index:200; box-shadow:0 4px 18px rgba(0,0,0,0.25); }
23334 .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
23335 .stat-chip:hover .stat-chip-tip { opacity:1; transform:translateX(-50%) translateY(0); }
23336 .cocomo-box { background:var(--surface); border:1px solid var(--line); border-radius:14px; padding:20px 22px; }
23337 .cocomo-box-head { display:flex; align-items:center; gap:10px; margin-bottom:16px; padding-bottom:14px; border-bottom:1px solid var(--line); flex-wrap:wrap; }
23338 .cocomo-box-title { font-size:18px; font-weight:750; color:var(--text); letter-spacing:-0.01em; }
23339 .cocomo-mode-pill-wrap { position:relative; display:inline-flex; align-items:center; cursor:help; }
23340 .cocomo-mode-pill { display:inline-flex; align-items:center; padding:3px 10px; border-radius:999px; background:var(--surface-3); border:1px solid var(--line-strong); font-size:11px; font-weight:700; color:var(--muted); }
23341 .cocomo-mode-tip { position:absolute; top:calc(100% + 8px); left:0; transform:translateY(-7px); background:var(--text); color:var(--bg); padding:9px 13px; border-radius:8px; font-size:11px; font-weight:500; line-height:1.55; white-space:normal; max-width:300px; min-width:180px; pointer-events:none; opacity:0; transition:opacity .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1); z-index:300; box-shadow:0 4px 18px rgba(0,0,0,0.25); }
23342 .cocomo-mode-tip::before { content:''; position:absolute; bottom:100%; left:14px; border:5px solid transparent; border-bottom-color:var(--text); }
23343 .cocomo-mode-pill-wrap:hover .cocomo-mode-tip { opacity:1; transform:translateY(0); }
23344 .cocomo-box-note { font-size:13px; color:var(--muted); margin-top:10px; line-height:1.6; }
23345 /* Submodule panel */
23346 .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
23347 /* Metrics tables stack */
23348 .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
23349 .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
23350 @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
23351 .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)); }
23352 .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
23353 /* Metrics table */
23354 .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
23355 .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
23356 .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; }
23357 .metrics-table thead th:not(:first-child) { text-align: right; }
23358 .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
23359 .metrics-table tbody tr:last-child td { border-bottom: none; }
23360 .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
23361 .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
23362 .metrics-table tbody tr:hover td { background: var(--surface-2); }
23363 .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
23364 .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; }
23365 .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
23366 .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
23367 .mt-val-pos { color: var(--pos); font-weight: 700; }
23368 .mt-val-neg { color: var(--neg); font-weight: 700; }
23369 .mt-val-zero { color: var(--muted); }
23370 .mt-val-mod { color: var(--oxide-2); }
23371 .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
23372 @media (max-width: 1180px) {
23373 .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
23374 .nav-project-slot, .nav-status { justify-content:flex-start; }
23375 .hero-top { flex-direction: column; }
23376 .run-mgmt-strip { flex-direction: column; }
23377 }
23378 .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;}
23379 @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));}}
23380 .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;white-space:nowrap;text-decoration:none;}.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;}
23381 /* ── Result-page chart controls ─────────────────────────────────────────── */
23382 .r-chart-section{margin-bottom:24px;}
23383 .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
23384 .section-pair > .panel{flex-shrink:0;}
23385 .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
23386 .r-chart-select{background:var(--surface-2);border:1px solid var(--line-strong);border-radius:8px;padding:4px 10px;color:var(--text);font-size:13px;font-weight:600;cursor:pointer;outline:none;}
23387 .r-chart-select:focus{border-color:var(--accent);}
23388 .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
23389 .r-chart-container svg{display:block;width:100%;height:auto;}
23390 .r-expand-btn{background:none;border:1px solid var(--line);border-radius:6px;cursor:pointer;color:var(--muted);padding:4px 10px;font-size:13px;line-height:1;transition:background .13s,color .13s;flex-shrink:0;white-space:nowrap;}
23391 .r-expand-btn:hover{background:var(--surface);color:var(--text);}
23392 .r-chart-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.55);z-index:9999;display:flex;align-items:center;justify-content:center;padding:24px;box-sizing:border-box;}
23393 .r-chart-modal{background:var(--bg);border-radius:16px;padding:24px 28px;max-width:960px;width:100%;max-height:85vh;overflow-y:auto;position:relative;box-shadow:0 24px 80px rgba(0,0,0,0.3);}
23394 .r-chart-modal-title{font-size:15px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:var(--text);margin:0 0 2px;display:block;}
23395 .r-chart-modal-subtitle{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 12px;display:block;letter-spacing:.02em;}
23396 .r-modal-header{display:flex;align-items:center;gap:12px;flex-wrap:nowrap;margin:0 0 16px;padding-right:44px;}
23397 .r-modal-header .r-chart-modal-title{flex:1 1 auto;margin:0;min-width:0;}
23398 .r-chart-modal-close{position:absolute;top:14px;right:18px;background:none;border:none;font-size:22px;cursor:pointer;color:var(--text);line-height:1;padding:0;}
23399 .r-chart-modal-close:hover{opacity:.7;}
23400 body.dark-theme .r-chart-modal{background:var(--surface);}
23401 .r-chart-container .rchit,.r-expand-modal-chart .rchit,#result-lang-charts .rchit,#result-lang-overview-modal-wrap .rchit{cursor:pointer;transition:opacity .17s,filter .17s,transform .17s;transform-box:fill-box;transform-origin:center center;}
23402 .r-chart-container .rchit:hover,.r-expand-modal-chart .rchit:hover,#result-lang-charts .rchit:hover,#result-lang-overview-modal-wrap .rchit:hover{filter:brightness(1.15) drop-shadow(0 2px 6px rgba(0,0,0,.18));transform:scale(1.05);}
23403 .lang-bar-row{cursor:pointer;transition:transform .2s cubic-bezier(.34,1.56,.64,1);}
23404 .lang-bar-row:hover{transform:translateY(-2px);}
23405 .lang-bar-row .rchit:hover{filter:none;transform:none;}
23406 .lang-bar-row:hover .rchit{filter:brightness(1.12);transform:scaleY(1.22);}
23407 .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
23408 .r-chart-tab{padding:4px 14px;border-radius:20px;border:1px solid var(--line-strong);cursor:pointer;font-size:12px;font-weight:700;color:var(--muted);background:var(--surface-2);transition:background .13s,color .13s;}
23409 .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
23410 .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
23411 @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
23412 @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
23413 #r-tt{display:none;position:fixed;background:rgba(15,10,6,.95);color:#fff;border-radius:10px;padding:8px 13px;font-size:12px;line-height:1.5;pointer-events:none;z-index:10001;box-shadow:0 4px 20px rgba(0,0,0,.32);border:1px solid rgba(255,255,255,.1);max-width:240px;white-space:nowrap;}
23414 .r-lang-overview{display:flex;gap:40px;align-items:center;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
23415 .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
23416 .r-lang-overview-cell p{margin:0;font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);text-align:center;}
23417 .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
23418 @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
23419 .r-viz-card{border:1px solid var(--line);border-radius:12px;padding:14px 16px;background:var(--surface);box-shadow:var(--shadow);display:flex;flex-direction:column;}
23420 .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
23421 .report-id-banner{background:var(--nav);color:#fff;font-size:11px;font-weight:700;letter-spacing:0.05em;display:flex;align-items:center;justify-content:center;height:27px;padding:0 16px;position:fixed;top:0;left:0;right:0;z-index:32;}
23422 .report-id-footer-banner{background:var(--nav);color:#fff;font-size:11px;font-weight:700;letter-spacing:0.05em;display:flex;align-items:center;justify-content:center;height:27px;padding:0 16px;position:fixed;bottom:0;left:0;right:0;z-index:32;}
23423 body.has-report-banner .top-nav{top:27px;}
23424 body.has-report-banner{padding-bottom:27px;}
23425 </style>
23426</head>
23427<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
23428 <div class="background-watermarks" aria-hidden="true">
23429 <img src="/images/logo/logo-text.png" alt="" />
23430 <img src="/images/logo/logo-text.png" alt="" />
23431 <img src="/images/logo/logo-text.png" alt="" />
23432 <img src="/images/logo/logo-text.png" alt="" />
23433 <img src="/images/logo/logo-text.png" alt="" />
23434 <img src="/images/logo/logo-text.png" alt="" />
23435 <img src="/images/logo/logo-text.png" alt="" />
23436 <img src="/images/logo/logo-text.png" alt="" />
23437 <img src="/images/logo/logo-text.png" alt="" />
23438 <img src="/images/logo/logo-text.png" alt="" />
23439 <img src="/images/logo/logo-text.png" alt="" />
23440 <img src="/images/logo/logo-text.png" alt="" />
23441 <img src="/images/logo/logo-text.png" alt="" />
23442 <img src="/images/logo/logo-text.png" alt="" />
23443 </div>
23444 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
23445 {% if let Some(banner) = report_header_footer %}
23446 <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
23447 {% endif %}
23448 <div class="top-nav">
23449 <div class="top-nav-inner">
23450 <a class="brand" href="/">
23451 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
23452 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
23453 </a>
23454 <div class="nav-project-slot">
23455 <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
23456 </div>
23457 <div class="nav-status">
23458 <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
23459 <div class="nav-dropdown">
23460 <a href="/view-reports" class="nav-dropdown-btn">View Reports <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></a>
23461 <div class="nav-dropdown-menu">
23462 <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
23463 </div>
23464 </div>
23465 <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
23466 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
23467 <div class="nav-dropdown">
23468 <a href="/git-browser" class="nav-dropdown-btn">Git Browser <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></a>
23469 <div class="nav-dropdown-menu">
23470 <a href="/integrations"><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>Integrations</a>
23471 </div>
23472 </div>
23473 <div class="server-status-wrap" id="server-status-wrap">
23474 <div class="nav-pill server-online-pill" id="server-status-pill">
23475 <span class="status-dot" id="status-dot"></span>
23476 <span id="server-status-label">Server</span>
23477 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
23478 </div>
23479 <div class="server-status-tip">
23480 OxideSLOC is running — accessible on your network.
23481 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
23482 </div>
23483 </div>
23484 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
23485 <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
23486 </button>
23487 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
23488 <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>
23489 <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>
23490 </button>
23491 </div>
23492 </div>
23493 </div>
23494
23495 <div class="page">
23496 <section class="hero">
23497 <div class="hero-top">
23498 <div>
23499 <div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;">
23500 <h1 class="hero-title" style="margin:0;">{{ report_title }}</h1>
23501 <span class="run-id-short-badge" title="Short run ID — matches the ID shown in View Reports">{{ run_id_short }}</span>
23502 <div class="soft-chip success" style="margin-left:auto;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>Run finished successfully</div>
23503 </div>
23504 </div>
23505 <div class="hero-quick-actions">
23506 {% if server_mode %}
23507 <button type="button" class="copy-button secondary" disabled title="Output folder is on the server — path is not meaningful for remote users" style="opacity:0.45;cursor:not-allowed;">Copy output folder</button>
23508 {% else %}
23509 <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
23510 {% endif %}
23511 <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
23512 {% if !server_mode %}
23513 <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
23514 {% endif %}
23515 <button class="copy-button secondary" id="download-bundle-btn" type="button">Download all artifacts</button>
23516 <button class="copy-button" id="delete-run-btn" type="button" style="background:#b23030;border-color:#b23030;color:#fff;box-shadow:0 12px 24px rgba(178,48,48,0.11);">Delete this run</button>
23517 </div>
23518 </div>
23519
23520 <!-- Run metadata chips: Run ID · Git Commit · Branch · Last Commit By -->
23521 <div class="run-id-row">
23522 <span class="run-id-chip" data-copy="{{ run_id }}">
23523 <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/></svg>Run ID</span>
23524 <span class="run-id-chip-value">{{ run_id }}</span>
23525 <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
23526 </span>
23527 {% match git_commit_long %}
23528 {% when Some with (long_sha) %}
23529 {% match git_commit_url %}
23530 {% when Some with (commit_url) %}
23531 <a class="run-id-chip" href="{{ commit_url }}" target="_blank" rel="noopener">
23532 <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><line x1="1" y1="12" x2="7" y2="12"/><line x1="17" y1="12" x2="23" y2="12"/></svg>Git Commit<svg class="chip-label-icon" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="margin-left:4px;opacity:0.7;"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></span>
23533 <span class="run-id-chip-value">{{ long_sha }}</span>
23534 <span class="chip-tooltip">Open commit on version control — click to navigate</span>
23535 </a>
23536 {% when None %}
23537 <span class="run-id-chip" data-copy="{{ long_sha }}">
23538 <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><line x1="1" y1="12" x2="7" y2="12"/><line x1="17" y1="12" x2="23" y2="12"/></svg>Git Commit</span>
23539 <span class="run-id-chip-value">{{ long_sha }}</span>
23540 <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
23541 </span>
23542 {% endmatch %}
23543 {% when None %}
23544 <span class="run-id-chip muted-chip">
23545 <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><line x1="1" y1="12" x2="7" y2="12"/><line x1="17" y1="12" x2="23" y2="12"/></svg>Git Commit</span>
23546 <span class="run-id-chip-value">Not detected</span>
23547 <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
23548 </span>
23549 {% endmatch %}
23550 {% match git_branch %}
23551 {% when Some with (branch) %}
23552 {% match git_branch_url %}
23553 {% when Some with (branch_url) %}
23554 <a class="run-id-chip" href="{{ branch_url }}" target="_blank" rel="noopener">
23555 <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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"/></svg>Branch<svg class="chip-label-icon" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="margin-left:4px;opacity:0.7;"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></span>
23556 <span class="run-id-chip-value">{{ branch }}</span>
23557 <span class="chip-tooltip">Open branch on version control — click to navigate</span>
23558 </a>
23559 {% when None %}
23560 <span class="run-id-chip">
23561 <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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"/></svg>Branch</span>
23562 <span class="run-id-chip-value">{{ branch }}</span>
23563 <span class="chip-tooltip">Git branch active at scan time</span>
23564 </span>
23565 {% endmatch %}
23566 {% when None %}
23567 <span class="run-id-chip muted-chip">
23568 <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><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"/></svg>Branch</span>
23569 <span class="run-id-chip-value">Not detected</span>
23570 <span class="chip-tooltip">No Git branch was found for this scan</span>
23571 </span>
23572 {% endmatch %}
23573 {% match git_author %}
23574 {% when Some with (author) %}
23575 <span class="run-id-chip" data-author="{{ author }}">
23576 <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>Last Commit By</span>
23577 <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
23578 <span class="chip-tooltip">Author of the most recent commit at scan time</span>
23579 </span>
23580 {% when None %}
23581 <span class="run-id-chip muted-chip">
23582 <span class="run-id-chip-label"><svg class="chip-label-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>Last Commit By</span>
23583 <span class="run-id-chip-value">Not detected</span>
23584 <span class="chip-tooltip">No commit author was found for this scan</span>
23585 </span>
23586 {% endmatch %}
23587 </div>
23588
23589 <!-- Scan metadata row -->
23590 <div class="meta">
23591 <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
23592 <span class="meta-chip">Scanned <b class="ts-local" data-utc-ms="{{ scan_time_utc_ms }}">{{ scan_time_display }}</b></span>
23593 <span class="meta-chip">OS <b>{{ os_display }}</b></span>
23594 <span class="meta-chip">Files analyzed <b>{{ files_analyzed|commas }}</b></span>
23595 <span class="meta-chip">Files skipped <b>{{ files_skipped|commas }}</b></span>
23596 </div>
23597
23598 <!-- All summary stat chips in one unified strip (8 columns) -->
23599 <div class="summary-strip summary-strip-hero">
23600 <div class="stat-chip" data-raw="{{ physical_lines }}">
23601 <div class="stat-chip-label">Physical lines</div>
23602 <div class="stat-chip-val">{{ physical_lines }}</div>
23603 <div class="stat-chip-exact"></div>
23604 <div class="stat-chip-tip">Total lines across all analyzed files, including code, comments, and blank lines.</div>
23605 </div>
23606 <div class="stat-chip" data-raw="{{ code_lines }}">
23607 <div class="stat-chip-label">Code</div>
23608 <div class="stat-chip-val">{{ code_lines }}</div>
23609 <div class="stat-chip-exact"></div>
23610 <div class="stat-chip-tip">Lines containing executable source code, excluding comments and blanks.</div>
23611 </div>
23612 <div class="stat-chip" data-raw="{{ comment_lines }}">
23613 <div class="stat-chip-label">Comments</div>
23614 <div class="stat-chip-val">{{ comment_lines }}</div>
23615 <div class="stat-chip-exact"></div>
23616 <div class="stat-chip-tip">Lines consisting entirely of comments or inline documentation.</div>
23617 </div>
23618 <div class="stat-chip" data-raw="{{ blank_lines }}">
23619 <div class="stat-chip-label">Blank</div>
23620 <div class="stat-chip-val">{{ blank_lines }}</div>
23621 <div class="stat-chip-exact"></div>
23622 <div class="stat-chip-tip">Empty or whitespace-only lines used for readability and spacing.</div>
23623 </div>
23624 <div class="stat-chip" data-raw="{{ mixed_lines }}">
23625 <div class="stat-chip-label">Mixed separate</div>
23626 <div class="stat-chip-val">{{ mixed_lines }}</div>
23627 <div class="stat-chip-exact"></div>
23628 <div class="stat-chip-tip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div>
23629 </div>
23630 <div class="stat-chip" data-raw="{{ functions }}">
23631 <div class="stat-chip-label">Functions</div>
23632 <div class="stat-chip-val">{{ functions }}</div>
23633 <div class="stat-chip-exact"></div>
23634 <div class="stat-chip-tip">Best-effort count of function/method definitions detected across all source files.</div>
23635 </div>
23636 <div class="stat-chip" data-raw="{{ classes }}">
23637 <div class="stat-chip-label">Classes / Types</div>
23638 <div class="stat-chip-val">{{ classes }}</div>
23639 <div class="stat-chip-exact"></div>
23640 <div class="stat-chip-tip">Best-effort count of class, struct, interface, and type definitions.</div>
23641 </div>
23642 <div class="stat-chip" data-raw="{{ variables }}">
23643 <div class="stat-chip-label">Variables</div>
23644 <div class="stat-chip-val">{{ variables }}</div>
23645 <div class="stat-chip-exact"></div>
23646 <div class="stat-chip-tip">Best-effort count of variable and constant declarations.</div>
23647 </div>
23648 <div class="stat-chip" data-raw="{{ imports }}">
23649 <div class="stat-chip-label">Imports</div>
23650 <div class="stat-chip-val">{{ imports }}</div>
23651 <div class="stat-chip-exact"></div>
23652 <div class="stat-chip-tip">Best-effort count of import, include, and module-use statements.</div>
23653 </div>
23654 <div class="stat-chip" data-raw="{{ test_count }}">
23655 <div class="stat-chip-label">Tests</div>
23656 <div class="stat-chip-val">{{ test_count }}</div>
23657 <div class="stat-chip-exact"></div>
23658 <div class="stat-chip-tip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div>
23659 </div>
23660 <div class="stat-chip" data-density data-code="{{ code_lines }}" data-physical="{{ physical_lines }}">
23661 <div class="stat-chip-label">Code density</div>
23662 <div class="stat-chip-val stat-chip-density-val">—</div>
23663 <div class="stat-chip-exact"></div>
23664 <div class="stat-chip-tip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div>
23665 </div>
23666 <div class="stat-chip" data-raw="{{ files_analyzed }}">
23667 <div class="stat-chip-label">Files analyzed</div>
23668 <div class="stat-chip-val">{{ files_analyzed }}</div>
23669 <div class="stat-chip-exact"></div>
23670 <div class="stat-chip-tip">Total number of source files included in this analysis.</div>
23671 </div>
23672 {% if cyclomatic_complexity > 0 %}
23673 <div class="stat-chip" data-raw="{{ cyclomatic_complexity }}" {% if complexity_alert > 0 && cyclomatic_complexity > complexity_alert as u64 %}style="border-color:var(--oxide-2);"{% endif %}>
23674 <div class="stat-chip-label">Complexity score</div>
23675 <div class="stat-chip-val">{{ cyclomatic_complexity }}</div>
23676 <div class="stat-chip-exact"></div>
23677 <div class="stat-chip-tip">Sum of branch decision keywords (if, for, while, ||, &&, …) across all code lines — a lexical approximation of McCabe cyclomatic complexity.{% if complexity_alert > 0 %} Alert threshold: {{ complexity_alert }}.{% endif %}</div>
23678 </div>
23679 {% endif %}
23680 {% if let Some(ls) = lsloc %}
23681 <div class="stat-chip" data-raw="{{ ls }}">
23682 <div class="stat-chip-label">Logical SLOC</div>
23683 <div class="stat-chip-val">{{ ls }}</div>
23684 <div class="stat-chip-exact"></div>
23685 <div class="stat-chip-tip">Count of executable statements (semicolons for C/Java/Go/Rust; non-continuation lines for Python/Ruby/Shell). Normalises across formatting styles.</div>
23686 </div>
23687 {% endif %}
23688 {% if uloc > 0 %}
23689 <div class="stat-chip" data-raw="{{ uloc }}">
23690 <div class="stat-chip-label">Unique SLOC (ULOC)</div>
23691 <div class="stat-chip-val">{{ uloc }}</div>
23692 <div class="stat-chip-exact"></div>
23693 <div class="stat-chip-tip">Unique Lines of Code: distinct non-blank code lines across all files. Counts each line once regardless of how many files it appears in.</div>
23694 </div>
23695 {% endif %}
23696 {% if uloc > 0 && dryness_pct_str != "" %}
23697 <div class="stat-chip">
23698 <div class="stat-chip-label">DRYness</div>
23699 <div class="stat-chip-val">{{ dryness_pct_str }}%</div>
23700 <div class="stat-chip-exact"></div>
23701 <div class="stat-chip-tip">ULOC ÷ Code Lines — the fraction of code lines that are unique. Higher = less copy-paste across the codebase. 100% means every code line is distinct.</div>
23702 </div>
23703 {% endif %}
23704 {% if duplicate_group_count > 0 %}
23705 <div class="stat-chip" data-raw="{{ duplicate_group_count }}" style="border-color:rgba(179,93,51,0.4);">
23706 <div class="stat-chip-label">Duplicate groups</div>
23707 <div class="stat-chip-val">{{ duplicate_group_count }}</div>
23708 <div class="stat-chip-exact"></div>
23709 <div class="stat-chip-tip">Groups of files with identical content detected. These may inflate SLOC counts. Enable "Exclude duplicates" in scan settings to remove them from totals.</div>
23710 </div>
23711 {% endif %}
23712 <!-- Reserve "pad" card: revealed by JS only when the visible card count is
23713 odd, so the strip always forms exactly two full rows with every column
23714 aligned and every card the same width (no oversized card, no gap). -->
23715 <div class="stat-chip stat-chip-pad" data-raw="{{ test_assertion_count }}" style="display:none;">
23716 <div class="stat-chip-label">Assertions</div>
23717 <div class="stat-chip-val">{{ test_assertion_count }}</div>
23718 <div class="stat-chip-exact"></div>
23719 <div class="stat-chip-tip">Best-effort count of test assertion call lines (assertEquals, EXPECT_*, etc.) detected across all test files.</div>
23720 </div>
23721 </div>
23722
23723 {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
23724 <div class="compare-banner">
23725 <div class="compare-banner-body">
23726 <div class="compare-banner-top">
23727 <div class="compare-banner-meta">
23728 <span class="compare-label">Previous scan</span>
23729 <span class="compare-ts">{{ prev_ts }}</span>
23730 {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
23731 {% if let Some(prev_code) = prev_run_code_lines %}
23732 <div class="compare-banner-stats" style="margin-top:4px;">
23733 <span>Code before: <strong data-raw="{{ prev_code }}">{{ prev_code }}</strong></span>
23734 <span class="compare-arrow">→</span>
23735 <span>Code now: <strong data-raw="{{ code_lines }}">{{ code_lines }}</strong></span>
23736 {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+<span data-raw="{{ added }}">{{ added }}</span> added</span>{% endif %}
23737 {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">−<span data-raw="{{ removed }}">{{ removed }}</span> removed</span>{% endif %}
23738 </div>
23739 {% endif %}
23740 </div>
23741 {% if delta_lines_added.is_some() %}
23742 <div class="delta-cards-inline">
23743 <div class="delta-card-inline">
23744 <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v|commas }}{% else %}—{% endif %}</div>
23745 <div class="delta-card-lbl">lines added</div>
23746 <div class="delta-card-tip">Code lines added since the previous scan</div>
23747 </div>
23748 <div class="delta-card-inline">
23749 <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}−{{ v|commas }}{% else %}—{% endif %}</div>
23750 <div class="delta-card-lbl">lines removed</div>
23751 <div class="delta-card-tip">Code lines removed since the previous scan</div>
23752 </div>
23753 <div class="delta-card-inline">
23754 <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v|commas }}{% else %}—{% endif %}</div>
23755 <div class="delta-card-lbl">unmodified lines</div>
23756 <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
23757 </div>
23758 <div class="delta-card-inline">
23759 <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v|commas }}{% else %}—{% endif %}</div>
23760 <div class="delta-card-lbl">files modified</div>
23761 <div class="delta-card-tip">Files with at least one line changed</div>
23762 </div>
23763 <div class="delta-card-inline">
23764 <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v|commas }}{% else %}—{% endif %}</div>
23765 <div class="delta-card-lbl">files added</div>
23766 <div class="delta-card-tip">New files added since the previous scan</div>
23767 </div>
23768 <div class="delta-card-inline">
23769 <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v|commas }}{% else %}—{% endif %}</div>
23770 <div class="delta-card-lbl">files removed</div>
23771 <div class="delta-card-tip">Files deleted since the previous scan</div>
23772 </div>
23773 <div class="delta-card-inline">
23774 <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v|commas }}{% else %}—{% endif %}</div>
23775 <div class="delta-card-lbl">files unchanged</div>
23776 <div class="delta-card-tip">Files with no changes since the previous scan</div>
23777 </div>
23778 </div>
23779 {% else %}
23780 <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
23781 Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
23782 </p>
23783 {% endif %}
23784 </div>
23785 <div class="compare-banner-actions">
23786 <div class="compare-banner-actions-left">
23787 <a class="button secondary" href="/runs/result/{{ prev_id }}" style="white-space:nowrap;">View previous report</a>
23788 <a class="button secondary" href="/compare-scans" style="white-space:nowrap;">Compare scans</a>
23789 </div>
23790 <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;">Full diff →</a>
23791 </div>
23792 </div>
23793 </div>
23794 {% endif %}{% endif %}
23795
23796 <div class="action-grid">
23797 <div class="action-card">
23798 <h3>HTML report</h3>
23799 <div class="action-buttons">
23800 {% match html_url %}
23801 {% when Some with (url) %}
23802 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
23803 {% when None %}{% endmatch %}
23804 {% match html_download_url %}
23805 {% when Some with (url) %}
23806 <a class="button secondary" href="{{ url }}">Download HTML</a>
23807 {% when None %}{% endmatch %}
23808 {% match html_path %}
23809 {% when Some with (_path) %}{% when None %}{% endmatch %}
23810 <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
23811 </div>
23812 </div>
23813 <div class="action-card">
23814 <h3>PDF report</h3>
23815 <div class="action-buttons">
23816 {% match pdf_url %}
23817 {% when Some with (url) %}
23818 {% if pdf_generating %}
23819 <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
23820 <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>
23821 Generating PDF…
23822 </button>
23823 {% else %}
23824 <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
23825 {% endif %}
23826 {% when None %}
23827 {% match html_url %}
23828 {% when Some with (_hurl) %}
23829 <a class="button" href="/runs/pdf/{{ run_id }}" target="_blank" rel="noopener" id="pdf-open-btn">Generate PDF</a>
23830 <p class="action-empty-note" style="margin-top:6px;font-size:11px;">Generates the PDF report from the scan results. Usually completes within a few seconds.</p>
23831 {% when None %}
23832 <p class="action-empty-note" style="color:var(--muted);font-size:12px;background:rgba(0,0,0,0.04);border:1px solid var(--line);border-radius:8px;padding:10px 12px;">
23833 PDF could not be generated for this run — Chromium or Edge may not be installed. The HTML report is always available above.
23834 </p>
23835 {% endmatch %}
23836 {% endmatch %}
23837 {% match pdf_download_url %}
23838 {% when Some with (url) %}
23839 <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
23840 {% when None %}{% endmatch %}
23841 {% match pdf_url %}
23842 {% when Some with (_) %}
23843 <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
23844 {% when None %}{% endmatch %}
23845 </div>
23846 </div>
23847 <div class="action-card">
23848 <h3>JSON result</h3>
23849 <div class="action-buttons">
23850 {% match json_url %}
23851 {% when Some with (url) %}
23852 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
23853 {% when None %}{% endmatch %}
23854 {% match json_download_url %}
23855 {% when Some with (url) %}
23856 <a class="button secondary" href="{{ url }}">Download JSON</a>
23857 {% when None %}{% endmatch %}
23858 {% match json_path %}
23859 {% when Some with (_path) %}
23860 <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
23861 {% when None %}
23862 <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
23863 {% endmatch %}
23864 </div>
23865 </div>
23866 <div class="action-card">
23867 <h3>Scan config</h3>
23868 <div class="action-buttons">
23869 <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
23870 <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
23871 <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
23872 </div>
23873 </div>
23874 {% if confluence_configured %}
23875 <div class="action-card" id="confluenceCard">
23876 <h3>Confluence</h3>
23877 <div class="action-buttons">
23878 <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
23879 <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
23880 </div>
23881 <p class="action-empty-note" style="margin-top:6px;">Create or update a Confluence page with this scan result, or copy wiki markup for manual paste.</p>
23882 </div>
23883 {% endif %}
23884 </div>
23885 {% if confluence_configured %}
23886 <div id="confluenceModal" style="display:none;position:fixed;inset:0;z-index:500;background:rgba(0,0,0,0.45);align-items:center;justify-content:center;">
23887 <div style="background:var(--surface);border:1px solid var(--line);border-radius:14px;padding:28px 32px;max-width:480px;width:95%;box-shadow:0 16px 48px rgba(0,0,0,0.28);">
23888 <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
23889 <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
23890 <input id="confPageTitle" type="text" value="OxideSLOC — {{ report_title }}" style="width:100%;margin:5px 0 14px;padding:9px 12px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:13px;box-sizing:border-box;">
23891 <label style="font-size:12px;font-weight:700;color:var(--muted);">Report URL <span style="font-weight:400;">(optional — linked in page body)</span></label>
23892 <input id="confReportUrl" type="url" placeholder="http://127.0.0.1:4317/runs/result/{{ run_id }}" style="width:100%;margin:5px 0 14px;padding:9px 12px;border-radius:8px;border:1.5px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:13px;box-sizing:border-box;">
23893 <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
23894 <div style="display:flex;gap:10px;justify-content:flex-end;">
23895 <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
23896 <button class="button" id="confSubmitBtn" type="button">Post</button>
23897 </div>
23898 </div>
23899 </div>
23900 {% endif %}
23901 <div id="delete-run-modal" style="display:none;position:fixed;inset:0;z-index:500;background:rgba(0,0,0,0.90);align-items:center;justify-content:center;">
23902 <div style="background:var(--surface);border:1px solid var(--line);border-radius:22px;padding:56px 72px;max-width:820px;width:95%;box-shadow:0 24px 72px rgba(0,0,0,0.55);">
23903 <div style="font-size:28px;font-weight:800;margin-bottom:16px;color:#b23030;">Delete run — irreversible</div>
23904 <p style="font-size:17px;color:var(--text);margin:0 0 28px;">This will permanently delete all artifacts for this run from disk (HTML, PDF, JSON, CSV, scan config). <strong>This cannot be undone</strong> and the run will no longer be accessible by anyone.</p>
23905 <div id="delete-run-status" style="display:none;padding:14px 20px;border-radius:10px;font-size:15px;font-weight:600;margin-bottom:22px;"></div>
23906 <div style="display:flex;gap:18px;justify-content:flex-end;">
23907 <button class="button secondary" id="delete-run-cancel" type="button" style="font-size:15px;padding:12px 28px;">Cancel</button>
23908 <button class="button" id="delete-run-confirm" type="button" style="background:#b23030;border-color:#b23030;font-size:15px;padding:12px 28px;">Yes, delete permanently</button>
23909 </div>
23910 </div>
23911 </div>
23912 {% if !submodule_rows.is_empty() %}
23913 <div class="submodule-panel">
23914 <div class="toolbar-row">
23915 <div>
23916 <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
23917 <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
23918 </div>
23919 <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
23920 </div>
23921 <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
23922 <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
23923 <colgroup><col style="width:24%"><col style="width:22%"><col style="width:9%"><col style="width:9%"><col style="width:9%"><col style="width:9%"><col style="width:9%"><col style="width:9%"></colgroup>
23924 <thead>
23925 <tr>
23926 <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>
23927 <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;white-space:nowrap;">Path</th>
23928 <th style="padding:9px 2px;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;overflow:hidden;text-overflow:ellipsis;">Files</th>
23929 <th style="padding:9px 2px;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;overflow:hidden;text-overflow:ellipsis;">Physical</th>
23930 <th style="padding:9px 2px;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;overflow:hidden;text-overflow:ellipsis;">Code</th>
23931 <th style="padding:9px 2px;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;overflow:hidden;text-overflow:ellipsis;">Comments</th>
23932 <th style="padding:9px 2px;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;overflow:hidden;text-overflow:ellipsis;">Blank</th>
23933 <th style="padding:9px 8px;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>
23934 </tr>
23935 </thead>
23936 <tbody>
23937 {% for row in submodule_rows %}
23938 <tr>
23939 <td style="padding:10px 14px;border-bottom:1px solid var(--line);font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" title="{{ row.name }}"><strong>{{ row.name }}</strong></td>
23940 <td style="padding:10px 14px;border-bottom:1px solid var(--line);white-space:nowrap;overflow:hidden;" title="{{ row.relative_path }}"><code style="font-size:12px;white-space:nowrap;word-break:keep-all;overflow-wrap:normal;">{{ row.relative_path }}</code></td>
23941 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed|commas }}</td>
23942 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines|commas }}</td>
23943 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines|commas }}</td>
23944 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines|commas }}</td>
23945 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines|commas }}</td>
23946 <td style="padding:10px 8px;border-bottom:1px solid var(--line);text-align:center;white-space:nowrap;">{% if let Some(url) = row.html_url %}<a class="button" href="{{ url }}" target="_blank" rel="noopener" style="font-size:12px;padding:6px 10px;min-height:0;display:block;margin:0 auto;width:fit-content;">View</a>{% else %}<span style="color:var(--muted);font-size:12px;">—</span>{% endif %}</td>
23947 </tr>
23948 {% endfor %}
23949 </tbody>
23950 </table>
23951 </div>
23952 </div>
23953 {% endif %}
23954
23955 <div class="metrics-tables-stack">
23956
23957 <div class="metrics-table-wrap">
23958 <div class="metrics-table-title">Files</div>
23959 <table class="metrics-table">
23960 <thead>
23961 <tr>
23962 <th>Metric</th>
23963 <th>This Run</th>
23964 <th>Previous</th>
23965 <th>Change</th>
23966 </tr>
23967 </thead>
23968 <tbody>
23969 <tr>
23970 <td>Files analyzed</td>
23971 <td class="mt-val-large">{{ files_analyzed|commas }}</td>
23972 <td>{{ prev_fa_str|commas }}</td>
23973 <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str|commas }}</span></td>
23974 </tr>
23975 <tr>
23976 <td>Files skipped</td>
23977 <td>{{ files_skipped|commas }}</td>
23978 <td>{{ prev_fs_str|commas }}</td>
23979 <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str|commas }}</span></td>
23980 </tr>
23981 <tr>
23982 <td>Files modified</td>
23983 <td class="mt-val-na">—</td>
23984 <td class="mt-val-na">—</td>
23985 <td>{% if let Some(v) = delta_files_modified %}<span class="mt-val-mod">{{ v|commas }} modified</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
23986 </tr>
23987 <tr>
23988 <td>Files unchanged</td>
23989 <td class="mt-val-na">—</td>
23990 <td class="mt-val-na">—</td>
23991 <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
23992 </tr>
23993 </tbody>
23994 </table>
23995 </div>
23996
23997 <div class="metrics-table-wrap">
23998 <div class="metrics-table-title">Line Counts</div>
23999 <table class="metrics-table">
24000 <thead>
24001 <tr>
24002 <th>Metric</th>
24003 <th>This Run</th>
24004 <th>Previous</th>
24005 <th>Change</th>
24006 </tr>
24007 </thead>
24008 <tbody>
24009 <tr>
24010 <td>Physical lines</td>
24011 <td class="mt-val-large">{{ physical_lines|commas }}</td>
24012 <td>{{ prev_pl_str|commas }}</td>
24013 <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str|commas }}</span></td>
24014 </tr>
24015 <tr>
24016 <td>Code lines</td>
24017 <td class="mt-val-large">{{ code_lines|commas }}</td>
24018 <td>{{ prev_cl_str|commas }}</td>
24019 <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str|commas }}</span></td>
24020 </tr>
24021 <tr>
24022 <td>Comment lines</td>
24023 <td>{{ comment_lines|commas }}</td>
24024 <td>{{ prev_cml_str|commas }}</td>
24025 <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str|commas }}</span></td>
24026 </tr>
24027 <tr>
24028 <td>Blank lines</td>
24029 <td>{{ blank_lines|commas }}</td>
24030 <td>{{ prev_bl_str|commas }}</td>
24031 <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str|commas }}</span></td>
24032 </tr>
24033 <tr>
24034 <td>Mixed (separate)</td>
24035 <td>{{ mixed_lines|commas }}</td>
24036 <td class="mt-val-na">—</td>
24037 <td class="mt-val-na">—</td>
24038 </tr>
24039 </tbody>
24040 </table>
24041 </div>
24042
24043 <div class="metrics-tables-lower">
24044 <div class="metrics-table-wrap">
24045 <div class="metrics-table-title">Code Structure</div>
24046 <table class="metrics-table">
24047 <thead>
24048 <tr>
24049 <th>Metric</th>
24050 <th>This Run</th>
24051 </tr>
24052 </thead>
24053 <tbody>
24054 <tr>
24055 <td>Functions</td>
24056 <td>{{ functions|commas }}</td>
24057 </tr>
24058 <tr>
24059 <td>Classes / Types</td>
24060 <td>{{ classes|commas }}</td>
24061 </tr>
24062 <tr>
24063 <td>Variables</td>
24064 <td>{{ variables|commas }}</td>
24065 </tr>
24066 <tr>
24067 <td>Imports</td>
24068 <td>{{ imports|commas }}</td>
24069 </tr>
24070 </tbody>
24071 </table>
24072 </div>
24073
24074 <div class="metrics-table-wrap">
24075 <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
24076 <table class="metrics-table">
24077 <thead>
24078 <tr>
24079 <th>Metric</th>
24080 <th>Change</th>
24081 </tr>
24082 </thead>
24083 <tbody>
24084 <tr>
24085 <td>Lines added</td>
24086 <td>{% if let Some(v) = delta_lines_added %}<span class="mt-val-pos">+{{ v|commas }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
24087 </tr>
24088 <tr>
24089 <td>Lines removed</td>
24090 <td>{% if let Some(v) = delta_lines_removed %}<span class="mt-val-neg">−{{ v|commas }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
24091 </tr>
24092 <tr>
24093 <td>Lines modified (net)</td>
24094 <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str|commas }}</span></td>
24095 </tr>
24096 <tr>
24097 <td>Lines unmodified</td>
24098 <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
24099 </tr>
24100 </tbody>
24101 </table>
24102 </div>
24103 </div>
24104
24105 </div>
24106
24107 <div class="path-list">
24108 <div class="path-item">
24109 <div class="path-item-label">Project path</div>
24110 {% if project_path.is_empty() %}<code style="color:var(--muted)" title="The scanned project path was not recorded in this run's metadata.">Not recorded for this scan</code>{% else %}<code>{{ project_path }}</code>{% endif %}
24111 </div>
24112 <div class="path-item">
24113 <div class="path-item-label">Git branch</div>
24114 {% if let Some(branch) = git_branch %}
24115 <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
24116 {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
24117 {% else %}
24118 <code style="color:var(--muted)">—</code>
24119 {% endif %}
24120 </div>
24121 <div class="path-item">
24122 <div class="path-item-label">Output folder</div>
24123 <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
24124 </div>
24125 <div class="path-item">
24126 <div class="path-item-label">Run ID</div>
24127 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
24128 <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
24129 <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
24130 </div>
24131 </div>
24132 </div>
24133 </section>
24134
24135 {% if has_cocomo %}
24136 <div class="cocomo-box" style="margin-top:24px;">
24137 <div class="cocomo-box-head">
24138 <span class="cocomo-box-title">Constructive Cost Model — COCOMO I</span>
24139 <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
24140 <span class="cocomo-mode-pill">{{ cocomo_mode_label }} mode</span>
24141 <span class="cocomo-mode-tip">{{ cocomo_mode_tooltip }}</span>
24142 </span>
24143 </div>
24144 <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
24145 <div class="stat-chip">
24146 <div class="stat-chip-label">Person-months</div>
24147 <div class="stat-chip-val">{{ cocomo_effort_str|commas }}</div>
24148 <div class="stat-chip-tip">Total estimated developer effort to build this codebase from scratch. One person-month = one developer working full-time for one calendar month. Computed as 2.4 × KSLOC^1.05 ({{ cocomo_mode_label }} mode).</div>
24149 </div>
24150 <div class="stat-chip">
24151 <div class="stat-chip-label">Schedule (months)</div>
24152 <div class="stat-chip-val">{{ cocomo_duration_str|commas }}</div>
24153 <div class="stat-chip-tip">Estimated calendar duration assuming an optimally sized team. Computed as 2.5 × effort^0.38. Adding more people beyond this optimum rarely shortens the timeline.</div>
24154 </div>
24155 <div class="stat-chip">
24156 <div class="stat-chip-label">Avg. Team Size</div>
24157 <div class="stat-chip-val">{{ cocomo_staff_str|commas }}</div>
24158 <div class="stat-chip-tip">Average number of engineers working in parallel, derived as effort ÷ schedule. Actual headcount may peak higher during intensive phases of the project.</div>
24159 </div>
24160 <div class="stat-chip">
24161 <div class="stat-chip-label">Input KSLOC</div>
24162 <div class="stat-chip-val">{{ cocomo_ksloc_str|commas }}K</div>
24163 <div class="stat-chip-tip">KSLOC = Kilo Source Lines of Code (1 KSLOC = 1,000 lines). This is the primary input to the COCOMO model. Only executable code lines are counted — blank lines and comments are excluded from this total.</div>
24164 </div>
24165 </div>
24166 <div class="cocomo-box-note" style="white-space:nowrap;">COCOMO I (Constructive Cost Model) is a 1981 algorithmic model by Barry Boehm that converts SLOC into effort, schedule, and team-size estimates.<br>These are ballpark figures — actual outcomes vary widely by team experience, toolchain maturity, and domain complexity.</div>
24167 </div>
24168 {% endif %}
24169
24170 <!-- ── Tests & Coverage brief summary ────────────────────────────────── -->
24171 <div class="cocomo-box" style="margin-top:24px;">
24172 <div class="cocomo-box-head">
24173 <span class="cocomo-box-title">Tests & Coverage</span>
24174 {% if has_coverage_data %}
24175 <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
24176 <span class="cocomo-mode-pill" style="background:rgba(34,197,94,0.14);color:#16a34a;">Coverage data present</span>
24177 </span>
24178 {% endif %}
24179 </div>
24180 <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
24181 <div class="stat-chip">
24182 <div class="stat-chip-val" data-fmt="{{ test_count }}">{{ test_count|commas }}</div>
24183 <div class="stat-chip-label">Test Functions</div>
24184 <div class="stat-chip-tip">Lexically detected test case / function definitions</div>
24185 </div>
24186 <div class="stat-chip">
24187 {% if has_coverage_data %}
24188 <div class="stat-chip-val" style="color:#16a34a;">{{ cov_line_pct }}%</div>
24189 {% else %}
24190 <div class="stat-chip-val" style="color:var(--muted);">—</div>
24191 {% endif %}
24192 <div class="stat-chip-label">Line Coverage</div>
24193 <div class="stat-chip-tip">Overall line coverage from LCOV / Cobertura / JaCoCo data</div>
24194 </div>
24195 <div class="stat-chip">
24196 {% if !cov_fn_pct.is_empty() %}
24197 <div class="stat-chip-val" style="color:#16a34a;">{{ cov_fn_pct }}%</div>
24198 {% else %}
24199 <div class="stat-chip-val" style="color:var(--muted);">—</div>
24200 {% endif %}
24201 <div class="stat-chip-label">Fn Coverage</div>
24202 <div class="stat-chip-tip">Overall function coverage — requires function-level LCOV data</div>
24203 </div>
24204 <div class="stat-chip">
24205 {% if !cov_branch_pct.is_empty() %}
24206 <div class="stat-chip-val" style="color:#16a34a;">{{ cov_branch_pct }}%</div>
24207 {% else %}
24208 <div class="stat-chip-val" style="color:var(--muted);">—</div>
24209 {% endif %}
24210 <div class="stat-chip-label">Branch Coverage</div>
24211 <div class="stat-chip-tip">Overall branch coverage — requires branch-level LCOV data</div>
24212 </div>
24213 </div>
24214 {% if has_coverage_data %}
24215 <div class="cocomo-box-note">Lines instrumented: <strong>{{ cov_lines_summary }}</strong> · Open the full HTML report for a per-file breakdown.</div>
24216 {% else %}
24217 <div class="cocomo-box-note">No code coverage detected. Re-run with <code>--lcov-path <coverage.info></code> to populate this section.</div>
24218 {% endif %}
24219 </div>
24220
24221 <div class="section-pair">
24222 <section class="panel">
24223 <div class="toolbar-row">
24224 <div>
24225 <h2>Language Breakdown</h2>
24226 <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
24227 </div>
24228 <button class="r-expand-btn" id="result-lang-overview-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
24229 </div>
24230 <div id="result-lang-charts" style="margin:0 0 8px;"></div>
24231 </section>
24232
24233 <section class="panel r-chart-section">
24234 <div class="toolbar-row" style="margin-bottom:16px;">
24235 <div>
24236 <h2>Visualizations</h2>
24237 <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
24238 </div>
24239 </div>
24240
24241 <div class="r-viz-grid">
24242 <div class="r-viz-card">
24243 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
24244 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Language Composition</p>
24245 <button class="r-expand-btn" id="r-composition-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
24246 </div>
24247 <div class="r-chart-tab-bar">
24248 <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
24249 <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
24250 </div>
24251 <div class="r-chart-container" id="r-composition-chart"></div>
24252 </div>
24253 <div class="r-viz-card">
24254 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
24255 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Files vs Code Lines</p>
24256 <button class="r-expand-btn" id="r-scatter-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
24257 </div>
24258 <div class="r-chart-container" id="r-scatter-chart"></div>
24259 </div>
24260 {% if has_semantic_data %}
24261 <div class="r-viz-card">
24262 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
24263 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
24264 <select class="r-chart-select" id="r-semantic-metric">
24265 <option value="functions">Functions</option>
24266 <option value="classes">Classes</option>
24267 <option value="variables">Variables</option>
24268 <option value="imports">Imports</option>
24269 <option value="tests">Tests</option>
24270 </select>
24271 <button class="r-expand-btn" id="r-semantic-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
24272 </div>
24273 <div class="r-chart-container" id="r-semantic-chart"></div>
24274 </div>
24275 {% endif %}
24276 <div class="r-viz-card">
24277 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
24278 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Comment Density</p>
24279 <button class="r-expand-btn" id="r-density-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
24280 </div>
24281 <div class="r-chart-container" id="r-density-chart"></div>
24282 </div>
24283 <div class="r-viz-card">
24284 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
24285 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Avg Lines per File</p>
24286 <button class="r-expand-btn" id="r-avglines-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
24287 </div>
24288 <div class="r-chart-container" id="r-avglines-chart"></div>
24289 </div>
24290 <div class="r-viz-card">
24291 <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
24292 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Repository Overview</p>
24293 <select class="r-chart-select" id="r-sub-metric">
24294 <option value="code">Code Lines</option>
24295 <option value="comment">Comments</option>
24296 <option value="blank">Blank Lines</option>
24297 <option value="physical">Physical Lines</option>
24298 <option value="files">Files</option>
24299 </select>
24300 <select class="r-chart-select" id="r-sub-sort">
24301 <option value="desc">Value ↓</option>
24302 <option value="asc">Value ↑</option>
24303 <option value="name">Name A→Z</option>
24304 </select>
24305 <button class="r-expand-btn" id="r-submodule-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
24306 </div>
24307 <div class="r-chart-container" id="r-submodule-chart"></div>
24308 </div>
24309 </div>
24310
24311 </section>
24312 </div>
24313
24314 </div>
24315
24316 <div id="r-tt" aria-hidden="true"></div>
24317
24318 <script nonce="{{ csp_nonce }}">
24319 (function () {
24320 var body = document.body;
24321 var themeToggle = document.getElementById('theme-toggle');
24322 var storageKey = 'oxide-sloc-theme';
24323
24324 function applyTheme(theme) {
24325 body.classList.toggle('dark-theme', theme === 'dark');
24326 }
24327
24328 function loadSavedTheme() {
24329 try {
24330 var saved = localStorage.getItem(storageKey);
24331 if (saved === 'dark' || saved === 'light') {
24332 applyTheme(saved);
24333 }
24334 } catch (e) {}
24335 }
24336
24337 if (themeToggle) {
24338 themeToggle.addEventListener('click', function () {
24339 var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
24340 applyTheme(nextTheme);
24341 try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
24342 });
24343 }
24344
24345 Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
24346 button.addEventListener('click', function () {
24347 var value = button.getAttribute('data-copy-value') || '';
24348 if (!value) return;
24349 var originalText = button.textContent;
24350 function flashSuccess() {
24351 button.textContent = 'Copied!';
24352 setTimeout(function () { button.textContent = originalText; }, 1800);
24353 }
24354 function flashFail() {
24355 button.textContent = 'Copy failed';
24356 setTimeout(function () { button.textContent = originalText; }, 2000);
24357 }
24358 if (navigator.clipboard && navigator.clipboard.writeText) {
24359 navigator.clipboard.writeText(value).then(flashSuccess, function () {
24360 fallbackCopy(value, flashSuccess, flashFail);
24361 });
24362 } else {
24363 fallbackCopy(value, flashSuccess, flashFail);
24364 }
24365 });
24366 });
24367 function fallbackCopy(text, onSuccess, onFail) {
24368 try {
24369 var ta = document.createElement('textarea');
24370 ta.value = text;
24371 ta.style.position = 'fixed';
24372 ta.style.top = '-9999px';
24373 ta.style.left = '-9999px';
24374 document.body.appendChild(ta);
24375 ta.focus();
24376 ta.select();
24377 var ok = document.execCommand('copy');
24378 document.body.removeChild(ta);
24379 if (ok) { onSuccess(); } else { onFail(); }
24380 } catch (e) { onFail(); }
24381 }
24382
24383 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
24384 btn.addEventListener('click', function () {
24385 var folder = btn.getAttribute('data-folder') || '';
24386 if (!folder) return;
24387 var orig = btn.textContent;
24388 fetch('/open-path?path=' + encodeURIComponent(folder))
24389 .then(function (r) { return r.json(); })
24390 .then(function (d) {
24391 if (d && d.server_mode_disabled) {
24392 window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
24393 } else if (d && d.ok) {
24394 btn.textContent = 'Opened!';
24395 setTimeout(function () { btn.textContent = orig; }, 1800);
24396 }
24397 })
24398 .catch(function () {
24399 btn.textContent = 'Failed';
24400 setTimeout(function () { btn.textContent = orig; }, 2000);
24401 });
24402 });
24403 });
24404
24405 loadSavedTheme();
24406
24407 // ── Compact number formatting for stat chips ──────────────────────────
24408 (function(){
24409 function fmt(n){var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}
24410 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
24411 var raw=parseInt(chip.getAttribute('data-raw'),10);
24412 if(isNaN(raw))return;
24413 var valEl=chip.querySelector('.stat-chip-val');
24414 if(valEl)valEl.textContent=fmt(raw);
24415 var exactEl=chip.querySelector('.stat-chip-exact');
24416 if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
24417 });
24418 // Code density chip
24419 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-density]')).forEach(function(chip){
24420 var code=parseInt(chip.getAttribute('data-code'),10);
24421 var phys=parseInt(chip.getAttribute('data-physical'),10);
24422 if(isNaN(code)||isNaN(phys)||phys===0)return;
24423 var pct=(code/phys*100).toFixed(1)+'%';
24424 var valEl=chip.querySelector('.stat-chip-val');
24425 if(valEl)valEl.textContent=pct;
24426 });
24427 // Populate author handle from data-author attribute
24428 Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-author]')).forEach(function(chip){
24429 var author=chip.getAttribute('data-author');
24430 var el=chip.querySelector('.author-handle');
24431 if(el)el.textContent='/'+author.replace(/\s+/g,'');
24432 });
24433 // Click-to-copy on run-id-chip elements
24434 Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-copy]')).forEach(function(chip){
24435 chip.addEventListener('click',function(){
24436 var val=chip.getAttribute('data-copy');
24437 if(!val)return;
24438 if(navigator.clipboard){navigator.clipboard.writeText(val).catch(function(){});}
24439 else{var ta=document.createElement('textarea');ta.value=val;document.body.appendChild(ta);ta.select();try{document.execCommand('copy');}catch(e){}document.body.removeChild(ta);}
24440 chip.classList.add('chip-copied-flash');
24441 setTimeout(function(){chip.classList.remove('chip-copied-flash');},900);
24442 });
24443 });
24444 // Format delta card values with data-raw using comma-separated full numbers
24445 Array.prototype.slice.call(document.querySelectorAll('.delta-cards-inline .delta-card-inline[data-raw]')).forEach(function(card){
24446 var raw=parseInt(card.getAttribute('data-raw'),10);
24447 if(isNaN(raw))return;
24448 var valEl=card.querySelector('.delta-card-val');
24449 if(valEl)valEl.textContent=raw.toLocaleString();
24450 });
24451 // Format code-before / code-now numbers in the compare banner stats line
24452 Array.prototype.slice.call(document.querySelectorAll('.compare-banner-stats [data-raw]')).forEach(function(el){
24453 var raw=parseInt(el.getAttribute('data-raw'),10);
24454 if(!isNaN(raw))el.textContent=raw.toLocaleString();
24455 });
24456 })();
24457
24458 // ── Shared tooltip for all result-page charts ─────────────────────────
24459 var rTT=(function(){
24460 var el=document.getElementById('r-tt');
24461 if(!el)return{s:function(){},h:function(){},m:function(){}};
24462 function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
24463 function hide(){el.style.display='none';}
24464 function move(e){
24465 var x=e.clientX+16,y=e.clientY-12;
24466 var r=el.getBoundingClientRect();
24467 if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
24468 if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
24469 el.style.left=x+'px';el.style.top=y+'px';
24470 }
24471 return{s:show,h:hide,m:move};
24472 })();
24473 window.rTT=rTT;
24474
24475 // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
24476 (function(){
24477 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
24478 document.addEventListener('mouseover',function(e){
24479 var t=e.target;
24480 while(t&&t.getAttribute){
24481 var l=t.getAttribute('data-ttl');
24482 if(l!==null){
24483 var v=t.getAttribute('data-ttv')||'';
24484 rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v).replace(/\n/g,'<br>'));
24485 return;
24486 }
24487 t=t.parentNode;
24488 }
24489 });
24490 document.addEventListener('mouseout',function(e){
24491 var t=e.target;
24492 while(t&&t.getAttribute){
24493 if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
24494 t=t.parentNode;
24495 }
24496 });
24497 document.addEventListener('mousemove',function(e){
24498 var el=document.getElementById('r-tt');
24499 if(el&&el.style.display!=='none')rTT.m(e);
24500 });
24501 window.addEventListener('blur',function(){rTT.h();});
24502 document.addEventListener('visibilitychange',function(){if(document.hidden)rTT.h();});
24503 })();
24504
24505 // ── Language overview charts ───────────────────────────────────────────
24506 (function(){
24507 var D={{ lang_chart_json|safe }};
24508 if(!D||!D.length)return;
24509 var el=document.getElementById('result-lang-charts');
24510 if(!el)return;
24511 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
24512 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
24513 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
24514 function fmt(n){var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}
24515 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
24516 function px(n){return Math.round(n);}
24517 function tt(label,val){var l=String(label).replace(/&/g,'&').replace(/"/g,'"'),v=String(val).replace(/&/g,'&').replace(/"/g,'"');return' class="rchit" data-ttl="'+l+'" data-ttv="'+v+'"';}
24518 // Largest font size (<=10) at which `t` fits in a `w`-wide segment, or 0 if
24519 // it cannot fit legibly even at the 6.5 floor. Lets bar labels shrink to fit
24520 // instead of vanishing; the SVG scales up in Full View so small fonts stay legible.
24521 function fitFs(t,w){var fs=Math.min(10,(w-4)/((String(t).length||1)*0.58));return fs>=6.5?Math.round(fs*10)/10:0;}
24522 var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
24523
24524 // Donut chart — height matches the stacked-bar chart so both panels align
24525 var rHb_d=28;
24526 var DH=Math.max(220,D.length*rHb_d+32);
24527 var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48;
24528 var legX=208,DW=395;
24529 var legCount=D.length;
24530 var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
24531 var legYStart=Math.round((DH-legCount*legSpacing)/2);
24532 var ds='<svg id="dnt-svg" viewBox="0 0 '+DW+' '+DH+'" width="'+DW+'" height="'+DH+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
24533 // One shared transition on every donut element so slices, leader lines,
24534 // outside labels, % labels and the legend all animate together as a single
24535 // picture when a language is hovered. Slices scale from the donut centre.
24536 ds+='<style>#dnt-svg path,#dnt-svg circle,#dnt-svg line,#dnt-svg text,#dnt-svg g{transition:opacity .22s ease,filter .22s ease,transform .22s ease,stroke-width .22s ease;}#dnt-svg path,#dnt-svg circle{transform-origin:'+cx+'px '+cy+'px;}</style>';
24537 if(D.length===1){
24538 var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
24539 ds+='<circle'+tt(D[0].lang,fmt(D[0].code)+' code lines')+' data-lang="'+esc(D[0].lang)+'" cx="'+cx+'" cy="'+cy+'" r="'+rm+'" fill="none" stroke="'+COLS[0]+'" stroke-width="'+rsw+'"/>';
24540 } else {
24541 var smalls=[];
24542 var ang=-Math.PI/2;
24543 D.forEach(function(d,i){
24544 var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
24545 var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
24546 var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
24547 var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
24548 var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
24549 var pct=Math.round(d.code/tot*100);
24550 ds+='<path'+tt(d.lang,fmt(d.code)+' code lines ('+pct+'%)')+' data-lang="'+esc(d.lang)+'" 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"/>';
24551 if(pct>=5){var mAng=ang+sw/2,mR=(Ro+Ri)/2;ds+='<text data-lang="'+esc(d.lang)+'" x="'+px(cx+mR*Math.cos(mAng))+'" y="'+px(cy+mR*Math.sin(mAng))+'" text-anchor="middle" dominant-baseline="middle" font-family="'+FONT+'" font-size="10" font-weight="700" fill="white" style="pointer-events:none;">'+pct+'%</text>';}else if(pct>0){smalls.push({mAng:ang+sw/2,pct:pct,lang:d.lang,col:COLS[i%COLS.length]});}
24552 ang+=sw;
24553 });
24554 // Small slices (<5%) get outside labels positioned near each slice's own
24555 // angular position (a slice on the left gets its label/leader on the left),
24556 // then nudged apart horizontally so text never overlaps. Leader lines point
24557 // from each slice to its label. Horizontal text keeps long names legible;
24558 // the whole SVG scales up in Full View so these stay readable there too.
24559 if(smalls.length){
24560 smalls.sort(function(a,b){return a.mAng-b.mAng;});
24561 var sPad=6,sRowY=11;
24562 smalls.forEach(function(sm){sm.txt=sm.lang+' '+sm.pct+'%';sm.w=sm.txt.length*5+8;sm.x=Math.max(sPad+sm.w/2,Math.min(DW-sPad-sm.w/2,cx+(Ro+14)*Math.cos(sm.mAng)));});
24563 for(var si=1;si<smalls.length;si++){var mnX=smalls[si-1].x+smalls[si-1].w/2+smalls[si].w/2+3;if(smalls[si].x<mnX)smalls[si].x=mnX;}
24564 var sLast=smalls[smalls.length-1],sOver=sLast.x+sLast.w/2-(DW-sPad);
24565 if(sOver>0)smalls.forEach(function(sm){sm.x-=sOver;});
24566 smalls.forEach(function(sm){
24567 var axx=cx+Ro*Math.cos(sm.mAng),ayy=cy+Ro*Math.sin(sm.mAng);
24568 ds+='<line data-lang="'+esc(sm.lang)+'" x1="'+px(axx)+'" y1="'+px(ayy)+'" x2="'+px(sm.x)+'" y2="'+px(sRowY+4)+'" stroke="'+sm.col+'" stroke-width="1" opacity="0.5" style="pointer-events:none;"/>';
24569 ds+='<text data-lang="'+esc(sm.lang)+'" x="'+px(sm.x)+'" y="'+px(sRowY)+'" text-anchor="middle" font-family="'+FONT+'" font-size="9" font-weight="700" fill="'+sm.col+'" style="cursor:pointer;">'+esc(sm.txt)+'</text>';
24570 });
24571 }
24572 }
24573 ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
24574 ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
24575 D.forEach(function(d,i){
24576 var ly=legYStart+i*legSpacing;
24577 var pctL=Math.round(d.code/tot*100);
24578 var ttL=String(d.lang).replace(/&/g,'&').replace(/"/g,'"');
24579 var ttV=(fmt(d.code)+' code lines ('+pctL+'%)').replace(/&/g,'&').replace(/"/g,'"');
24580 ds+='<g data-lang="'+esc(d.lang)+'" data-ttl="'+ttL+'" data-ttv="'+ttV+'" style="cursor:pointer;">';
24581 ds+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+(legSpacing||14)+'" fill="transparent"/>';
24582 ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
24583 ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
24584 ds+='<text x="'+(legX+100)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(10,legSpacing-3)+'" font-weight="700" fill="#7b675b">'+fmt(d.code)+' ('+pctL+'%)</text>';
24585 ds+='</g>';
24586 });
24587 ds+='</svg>';
24588
24589 // Horizontal stacked-bar chart — fills container width
24590 var maxT=Math.max.apply(null,D.map(function(d){return d.physical||d.code+d.comments+d.blanks;}))||1;
24591 var LW=108,BW=260,svgW=LW+BW+68;
24592 var barRhb=Math.min(48,Math.max(28,Math.floor((DH-32)/D.length)));
24593 var barBH=Math.min(32,Math.round(barRhb*0.7));
24594 var SH=DH;
24595 var barTopPad=Math.max(6,Math.round((SH-D.length*barRhb-18)/2));
24596 var bs='<svg viewBox="0 0 '+svgW+' '+SH+'" width="'+svgW+'" height="'+SH+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
24597 D.forEach(function(d,i){
24598 var y=barTopPad+i*barRhb,x=LW;
24599 var phys=d.physical||d.code+d.comments+d.blanks;
24600 var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
24601 var lmid=y+barBH/2+4;
24602 // Combined breakdown shown when hovering the row, the language name, or the
24603 // total at the bar end (\n becomes a line break in the tooltip).
24604 var ttv='Code: '+fmt(d.code)+'\nComments: '+fmt(d.comments)+'\nBlank: '+fmt(d.blanks)+'\nTotal: '+fmt(phys);
24605 bs+='<g class="lang-bar-row">';
24606 // Hit area ends just past the total label so empty space to the right of the
24607 // bar does not trigger the tooltip — only the name, bar and total are hot.
24608 var hitW=px(LW+phys/maxT*BW+8+(String(fmt(phys)).length*6.8)+6);
24609 bs+='<rect'+tt(d.lang,ttv)+' x="0" y="'+y+'" width="'+hitW+'" height="'+barBH+'" fill="transparent" style="cursor:pointer;"/>';
24610 bs+='<text'+tt(d.lang,ttv)+' x="'+(LW-6)+'" y="'+lmid+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="#43342d" style="cursor:pointer;">'+esc(d.lang)+'</text>';
24611 if(cW>0.5){bs+='<rect'+tt(d.lang+' Code',fmt(d.code)+' lines')+' data-kind="code" x="'+px(x)+'" y="'+y+'" width="'+px(cW)+'" height="'+barBH+'" fill="'+OX+'" rx="0"/>';var _fc=fitFs(fmt(d.code),cW);if(_fc)bs+='<text x="'+px(x+cW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fc+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.code)+'</text>';x+=cW;}
24612 if(cmW>0.5){bs+='<rect'+tt(d.lang+' Comments',fmt(d.comments)+' lines')+' data-kind="comment" x="'+px(x)+'" y="'+y+'" width="'+px(cmW)+'" height="'+barBH+'" fill="'+GN+'" rx="0"/>';var _fm=fitFs(fmt(d.comments),cmW);if(_fm)bs+='<text x="'+px(x+cmW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fm+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.comments)+'</text>';x+=cmW;}
24613 if(blW>0.5){bs+='<rect'+tt(d.lang+' Blank',fmt(d.blanks)+' lines')+' data-kind="blank" x="'+px(x)+'" y="'+y+'" width="'+px(blW)+'" height="'+barBH+'" fill="'+GY+'" rx="0"/>';var _fb=fitFs(fmt(d.blanks),blW);if(_fb)bs+='<text x="'+px(x+blW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fb+'" font-weight="700" fill="#555" style="pointer-events:none;">'+fmt(d.blanks)+'</text>';}
24614 bs+='<text'+tt(d.lang,ttv)+' x="'+px(LW+phys/maxT*BW+8)+'" y="'+lmid+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="#7b675b" style="cursor:pointer;">'+fmt(phys)+'</text>';
24615 bs+='</g>';
24616 });
24617 var ly=SH-14;
24618 var totC=D.reduce(function(a,d){return a+(d.code||0);},0);
24619 var totCm=D.reduce(function(a,d){return a+(d.comments||0);},0);
24620 var totBl=D.reduce(function(a,d){return a+(d.blanks||0);},0);
24621 var totAll=totC+totCm+totBl||1;
24622 function legTT(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'"')+'"';}
24623 var ttC=legTT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
24624 var ttCm=legTT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
24625 var ttBl=legTT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
24626 var legSt=LW+Math.max(0,Math.round((BW-194)/2));
24627 bs+='<g data-kind="code" style="cursor:pointer;">'
24628 +'<rect x="'+legSt+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC+'/>'
24629 +'<rect x="'+legSt+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC+'/>'
24630 +'<text x="'+(legSt+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>'
24631 +'</g>';
24632 bs+='<g data-kind="comment" style="cursor:pointer;">'
24633 +'<rect x="'+(legSt+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm+'/>'
24634 +'<rect x="'+(legSt+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm+'/>'
24635 +'<text x="'+(legSt+71)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>'
24636 +'</g>';
24637 bs+='<g data-kind="blank" style="cursor:pointer;">'
24638 +'<rect x="'+(legSt+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/>'
24639 +'<rect x="'+(legSt+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl+'/>'
24640 +'<text x="'+(legSt+158)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>'
24641 +'</g>';
24642 bs+='</svg>';
24643 el.innerHTML='<div class="r-lang-overview">'+
24644 '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
24645 '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
24646 '</div>';
24647 function wireDonutLegend(svg){
24648 if(!svg)return;
24649 // Every donut element carries data-lang: slices (path/circle), leader lines,
24650 // outside labels + % labels (text) and legend rows (g). Hovering any one of
24651 // them emphasises that language across all of them and fades the rest, so the
24652 // slice, its leader line, its label and its legend row move as one picture.
24653 var items=svg.querySelectorAll('[data-lang]');
24654 function emph(el,st){ // st: 1 = highlight, -1 = fade, 0 = reset
24655 var tag=el.tagName.toLowerCase();
24656 if(tag==='path'||tag==='circle'){
24657 if(st===1){el.style.opacity='1';el.style.filter='brightness(1.15) drop-shadow(0 3px 9px rgba(0,0,0,.28))';el.style.transform='scale(1.06)';}
24658 else if(st===-1){el.style.opacity='0.24';el.style.filter='none';el.style.transform='none';}
24659 else{el.style.opacity='';el.style.filter='';el.style.transform='';}
24660 }else if(tag==='line'){
24661 if(st===1){el.style.opacity='1';el.style.strokeWidth='1.8';}
24662 else if(st===-1){el.style.opacity='0.1';el.style.strokeWidth='';}
24663 else{el.style.opacity='';el.style.strokeWidth='';}
24664 }else if(tag==='text'){
24665 if(st===1){el.style.opacity='1';el.style.fontWeight='800';}
24666 else if(st===-1){el.style.opacity='0.18';el.style.fontWeight='';}
24667 else{el.style.opacity='';el.style.fontWeight='';}
24668 }else{ // legend group
24669 if(st===1){el.style.opacity='1';}
24670 else if(st===-1){el.style.opacity='0.4';}
24671 else{el.style.opacity='';}
24672 }
24673 }
24674 function hl(lang){for(var i=0;i<items.length;i++){emph(items[i],items[i].getAttribute('data-lang')===lang?1:-1);}}
24675 function rst(){for(var i=0;i<items.length;i++){emph(items[i],0);}}
24676 svg.addEventListener('mouseover',function(e){var t=e.target;while(t&&t!==svg){var l=t.getAttribute&&t.getAttribute('data-lang');if(l){hl(l);return;}t=t.parentNode;}rst();});
24677 svg.addEventListener('mousemove',function(e){var t=e.target;while(t&&t!==svg){if(t.getAttribute&&t.getAttribute('data-lang'))return;t=t.parentNode;}rst();});
24678 svg.addEventListener('mouseout',function(e){if(e.relatedTarget&&svg.contains(e.relatedTarget))return;rst();});
24679 }
24680 function wireMixLegend(svg){
24681 if(!svg)return;
24682 var legGs=svg.querySelectorAll('g[data-kind]');
24683 var allRects=svg.querySelectorAll('rect[data-kind]');
24684 if(!legGs.length)return;
24685 function hlKind(kind){for(var i=0;i<allRects.length;i++){var r=allRects[i];if(r.getAttribute('data-kind')===kind){r.style.opacity='1';r.style.filter='brightness(1.18) drop-shadow(0 2px 6px rgba(0,0,0,.22))';}else{r.style.opacity='0.18';r.style.filter='none';}}for(var j=0;j<legGs.length;j++){legGs[j].style.opacity=legGs[j].getAttribute('data-kind')===kind?'1':'0.45';}}
24686 function rst(){for(var i=0;i<allRects.length;i++){allRects[i].style.opacity='';allRects[i].style.filter='';}for(var j=0;j<legGs.length;j++){legGs[j].style.opacity='';}}
24687 for(var k=0;k<legGs.length;k++){(function(g){g.addEventListener('mouseenter',function(){hlKind(g.getAttribute('data-kind'));});g.addEventListener('mouseleave',rst);})(legGs[k]);}
24688 }
24689 wireDonutLegend(el.querySelector('svg'));
24690 wireMixLegend(el.querySelectorAll('svg')[1]);
24691
24692 // ── Language breakdown Full View expand ─────────────────────────────────
24693 var langOvBtn=document.getElementById('result-lang-overview-expand');
24694 if(langOvBtn){langOvBtn.addEventListener('click',function(){
24695 var src=document.getElementById('result-lang-charts');
24696 if(!src)return;
24697 var overlay=document.createElement('div');
24698 overlay.className='r-chart-modal-overlay';
24699 overlay.innerHTML='<div class="r-chart-modal" style="max-width:1600px;"><button class="r-chart-modal-close" aria-label="Close">×</button><div class="r-modal-header"><span class="r-chart-modal-title">Language Breakdown \u2014 Full View</span></div><div id="result-lang-overview-modal-wrap" style="width:100%;"></div></div>';
24700 document.body.appendChild(overlay);
24701 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
24702 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
24703 var wrap=document.getElementById('result-lang-overview-modal-wrap');
24704 if(wrap){
24705 wrap.innerHTML=src.innerHTML;
24706 var svgs=wrap.querySelectorAll('svg');
24707 for(var i=0;i<svgs.length;i++){
24708 svgs[i].removeAttribute('width');
24709 svgs[i].removeAttribute('height');
24710 svgs[i].style.cssText='display:block;width:100%;height:auto;';
24711 }
24712 var ov=wrap.querySelector('.r-lang-overview');
24713 if(ov){ov.style.flexWrap='nowrap';ov.style.alignItems='stretch';}
24714 var cells=wrap.querySelectorAll('.r-lang-overview-cell');
24715 if(cells.length>0)cells[0].style.cssText='flex:1 1 0;max-width:none;justify-content:center;';
24716 if(cells.length>1)cells[1].style.cssText='flex:1 1 0;max-width:none;';
24717 wireDonutLegend(wrap.querySelector('svg'));
24718 wireMixLegend(wrap.querySelectorAll('svg')[1]);
24719 requestAnimationFrame(function(){
24720 var ss=wrap.querySelectorAll('svg');
24721 if(ss.length>=2){var bh=ss[1].getBoundingClientRect().height;if(bh>0){ss[0].style.cssText='display:block;height:'+bh+'px;width:auto;max-width:100%;';}}
24722 });
24723 }
24724 });}
24725 })();
24726
24727 // ── Extended charts (composition, scatter, semantic, submodule) ─────────
24728 (function(){
24729 var LANG_D={{ lang_chart_json|safe }};
24730 var SCAT_D={{ scatter_chart_json|safe }};
24731 var SEM_D={{ semantic_chart_json|safe }};
24732 var SUB_D={{ submodule_chart_json|safe }};
24733 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
24734 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
24735 function fmt(n){var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}
24736 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
24737 function px(n){return Math.round(n);}
24738 function tt(label,val){var l=String(label).replace(/&/g,'&').replace(/"/g,'"'),v=String(val).replace(/&/g,'&').replace(/"/g,'"');return' class="rchit" data-ttl="'+l+'" data-ttv="'+v+'"';}
24739 // Largest font size (<=10) at which `t` fits in a `w`-wide bar segment, or 0
24740 // when it cannot fit legibly even at the 6.5 floor (labels shrink to fit
24741 // rather than disappear; the SVG scales up in Full View).
24742 function fitFs(t,w){var fs=Math.min(10,(w-4)/((String(t).length||1)*0.58));return fs>=6.5?Math.round(fs*10)/10:0;}
24743
24744 // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
24745 function renderCompositionInEl(el,mode,shOvr){
24746 if(!el||!LANG_D||!LANG_D.length)return;
24747 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
24748 var LW=110,SH=shOvr||300;
24749 var svgW=Math.max(320,el.offsetWidth||480);
24750 var BW=Math.max(120,svgW-LW-80);
24751 var legendH=24,topPad=4;
24752 var n=LANG_D.length||1;
24753 var rowTotal=Math.floor((SH-legendH-topPad)/n);
24754 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
24755 var s='<svg viewBox="0 0 '+svgW+' '+SH+'" width="'+svgW+'" height="'+SH+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
24756 var totC2=LANG_D.reduce(function(a,d){return a+(d.code||0);},0);
24757 var totCm2=LANG_D.reduce(function(a,d){return a+(d.comments||0);},0);
24758 var totBl2=LANG_D.reduce(function(a,d){return a+(d.blanks||0);},0);
24759 var totAll2=totC2+totCm2+totBl2||1;
24760 if(mode==='pct'){
24761 LANG_D.forEach(function(d,i){
24762 var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
24763 var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
24764 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
24765 var lmid=y+Math.floor(bH/2)+4;
24766 var ttvc='Code: '+fmt(d.code||0)+'\nComments: '+fmt(d.comments||0)+'\nBlank: '+fmt(d.blanks||0)+'\nTotal: '+fmt(d.physical||tot2);
24767 s+='<text'+tt(d.lang,ttvc)+' x="'+(LW-5)+'" y="'+lmid+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="currentColor" style="cursor:pointer;">'+esc(d.lang)+'</text>';
24768 if(cW>0.5){s+='<rect'+tt(d.lang+' Code',fmt(d.code||0)+' lines')+' data-kind="code" x="'+px(x)+'" y="'+y+'" width="'+px(cW)+'" height="'+bH+'" fill="'+OX+'"/>';var _fc=fitFs(fmt(d.code||0),cW);if(_fc)s+='<text x="'+px(x+cW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fc+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.code||0)+'</text>';x+=cW;}
24769 if(cmW>0.5){s+='<rect'+tt(d.lang+' Comments',fmt(d.comments||0)+' lines')+' data-kind="comment" x="'+px(x)+'" y="'+y+'" width="'+px(cmW)+'" height="'+bH+'" fill="'+GN+'"/>';var _fm=fitFs(fmt(d.comments||0),cmW);if(_fm)s+='<text x="'+px(x+cmW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fm+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.comments||0)+'</text>';x+=cmW;}
24770 if(blW>0.5){s+='<rect'+tt(d.lang+' Blank',fmt(d.blanks||0)+' lines')+' data-kind="blank" x="'+px(x)+'" y="'+y+'" width="'+px(blW)+'" height="'+bH+'" fill="'+GY+'"/>';var _fb=fitFs(fmt(d.blanks||0),blW);if(_fb)s+='<text x="'+px(x+blW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fb+'" font-weight="700" fill="#555" style="pointer-events:none;">'+fmt(d.blanks||0)+'</text>';}
24771 var pct=Math.round((d.code||0)/tot2*100);
24772 s+='<text'+tt(d.lang,ttvc)+' x="'+(LW+BW+4)+'" y="'+lmid+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor" style="cursor:pointer;">'+pct+'%</text>';
24773 });
24774 } else {
24775 var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
24776 LANG_D.forEach(function(d,i){
24777 var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
24778 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
24779 var lmid=y+Math.floor(bH/2)+4;
24780 var ttvc='Code: '+fmt(d.code||0)+'\nComments: '+fmt(d.comments||0)+'\nBlank: '+fmt(d.blanks||0)+'\nTotal: '+fmt(d.physical||(d.code||0)+(d.comments||0)+(d.blanks||0));
24781 s+='<text'+tt(d.lang,ttvc)+' x="'+(LW-5)+'" y="'+lmid+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="currentColor" style="cursor:pointer;">'+esc(d.lang)+'</text>';
24782 if(cW>0.5){s+='<rect'+tt(d.lang+' Code',fmt(d.code||0)+' lines')+' data-kind="code" x="'+px(x)+'" y="'+y+'" width="'+px(cW)+'" height="'+bH+'" fill="'+OX+'"/>';var _fc=fitFs(fmt(d.code||0),cW);if(_fc)s+='<text x="'+px(x+cW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fc+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.code||0)+'</text>';x+=cW;}
24783 if(cmW>0.5){s+='<rect'+tt(d.lang+' Comments',fmt(d.comments||0)+' lines')+' data-kind="comment" x="'+px(x)+'" y="'+y+'" width="'+px(cmW)+'" height="'+bH+'" fill="'+GN+'"/>';var _fm=fitFs(fmt(d.comments||0),cmW);if(_fm)s+='<text x="'+px(x+cmW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fm+'" font-weight="700" fill="#fff" style="pointer-events:none;">'+fmt(d.comments||0)+'</text>';x+=cmW;}
24784 if(blW>0.5){s+='<rect'+tt(d.lang+' Blank',fmt(d.blanks||0)+' lines')+' data-kind="blank" x="'+px(x)+'" y="'+y+'" width="'+px(blW)+'" height="'+bH+'" fill="'+GY+'"/>';var _fb=fitFs(fmt(d.blanks||0),blW);if(_fb)s+='<text x="'+px(x+blW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fb+'" font-weight="700" fill="#555" style="pointer-events:none;">'+fmt(d.blanks||0)+'</text>';}
24785 s+='<text'+tt(d.lang,ttvc)+' x="'+(LW+cW+cmW+blW+4)+'" y="'+lmid+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor" style="cursor:pointer;">'+fmt(d.physical||(d.code||0)+(d.comments||0)+(d.blanks||0))+'</text>';
24786 });
24787 }
24788 var ly=SH-legendH+4;
24789 var legSt2=LW+Math.max(0,Math.round((BW-194)/2));
24790 function legTT2(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'"')+'"';}
24791 var ttC2=legTT2('Code lines',fmt(totC2)+' total ('+Math.round(totC2/totAll2*100)+'%)');
24792 var ttCm2=legTT2('Comment lines',fmt(totCm2)+' total ('+Math.round(totCm2/totAll2*100)+'%)');
24793 var ttBl2=legTT2('Blank lines',fmt(totBl2)+' total ('+Math.round(totBl2/totAll2*100)+'%)');
24794 s+='<g data-kind="code" style="cursor:pointer;">'
24795 +'<rect x="'+legSt2+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC2+'/>'
24796 +'<rect x="'+legSt2+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC2+'/>'
24797 +'<text x="'+(legSt2+13)+'" y="'+(ly+9)+'"'+ttC2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Code</text>'
24798 +'</g>';
24799 s+='<g data-kind="comment" style="cursor:pointer;">'
24800 +'<rect x="'+(legSt2+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm2+'/>'
24801 +'<rect x="'+(legSt2+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm2+'/>'
24802 +'<text x="'+(legSt2+71)+'" y="'+(ly+9)+'"'+ttCm2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Comments</text>'
24803 +'</g>';
24804 s+='<g data-kind="blank" style="cursor:pointer;">'
24805 +'<rect x="'+(legSt2+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl2+'/>'
24806 +'<rect x="'+(legSt2+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl2+'/>'
24807 +'<text x="'+(legSt2+158)+'" y="'+(ly+9)+'"'+ttBl2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Blanks</text>'
24808 +'</g>';
24809 s+='</svg>';
24810 el.innerHTML=s;
24811 wireMixLegendEl(el);
24812 }
24813 function wireMixLegendEl(container){
24814 var svg=container&&container.querySelector('svg');
24815 if(!svg)return;
24816 var legGs=svg.querySelectorAll('g[data-kind]');
24817 var allRects=svg.querySelectorAll('rect[data-kind]');
24818 if(!legGs.length)return;
24819 function hlKind(kind){for(var i=0;i<allRects.length;i++){var r=allRects[i];if(r.getAttribute('data-kind')===kind){r.style.opacity='1';r.style.filter='brightness(1.18) drop-shadow(0 2px 6px rgba(0,0,0,.22))';}else{r.style.opacity='0.18';r.style.filter='none';}}for(var j=0;j<legGs.length;j++){legGs[j].style.opacity=legGs[j].getAttribute('data-kind')===kind?'1':'0.45';}}
24820 function rst(){for(var i=0;i<allRects.length;i++){allRects[i].style.opacity='';allRects[i].style.filter='';}for(var j=0;j<legGs.length;j++){legGs[j].style.opacity='';}}
24821 for(var k=0;k<legGs.length;k++){(function(g){g.addEventListener('mouseenter',function(){hlKind(g.getAttribute('data-kind'));});g.addEventListener('mouseleave',rst);})(legGs[k]);}
24822 }
24823 function renderComposition(mode){renderCompositionInEl(document.getElementById('r-composition-chart'),mode,0);}
24824 renderComposition('abs');
24825 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
24826 btn.addEventListener('click',function(){
24827 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
24828 btn.classList.add('active');
24829 renderComposition(btn.getAttribute('data-rcomp'));
24830 });
24831 });
24832
24833 // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
24834 function wireScatterLegend(container){
24835 var svg=container&&container.querySelector('svg');
24836 if(!svg)return;
24837 var legGs=svg.querySelectorAll('g[data-lang]');
24838 var circs=svg.querySelectorAll('circle[data-lang]');
24839 var labs=svg.querySelectorAll('text[data-lang]');
24840 if(!legGs.length)return;
24841 // Raise an element to the top of its parent so the hovered bubble and its
24842 // name/number labels sit above overlapping neighbours (clustered bubbles
24843 // otherwise bury the one you are trying to read).
24844 function raise(el){if(el&&el.parentNode)el.parentNode.appendChild(el);}
24845 function hl(lang){
24846 for(var i=0;i<circs.length;i++){var c=circs[i];if(c.getAttribute('data-lang')===lang){c.style.opacity='1';c.style.filter='brightness(1.18) drop-shadow(0 2px 8px rgba(0,0,0,.28))';raise(c);}else{c.style.opacity='0.1';c.style.filter='none';}}
24847 for(var t=0;t<labs.length;t++){var lx=labs[t];if(lx.getAttribute('data-lang')===lang){lx.style.opacity='1';lx.style.fontWeight='800';raise(lx);}else{lx.style.opacity='0.07';}}
24848 for(var j=0;j<legGs.length;j++){legGs[j].style.opacity=legGs[j].getAttribute('data-lang')===lang?'1':'0.38';}}
24849 function rst(){for(var i=0;i<circs.length;i++){circs[i].style.opacity='';circs[i].style.filter='';}for(var t=0;t<labs.length;t++){labs[t].style.opacity='';labs[t].style.fontWeight='';}for(var j=0;j<legGs.length;j++){legGs[j].style.opacity='';}}
24850 for(var k=0;k<legGs.length;k++){(function(g){g.addEventListener('mouseenter',function(){hl(g.getAttribute('data-lang'));});g.addEventListener('mouseleave',rst);})(legGs[k]);}
24851 }
24852 function renderScatterInEl(el,hOvr){
24853 if(!el||!SCAT_D||!SCAT_D.length)return;
24854 var n=SCAT_D.length;
24855 var H=hOvr||300,PL=52,PB=36,PT=44;
24856 var W=Math.max(320,el.offsetWidth||480);
24857 var cH=H-PT-PB;
24858 // Legend: max 2 columns, fills vertical space. The compact card shows the
24859 // top languages by code lines plus a "+N more" row linking to Full View;
24860 // Full View (hOvr set) shows every language across up to 2 tall columns.
24861 var compact=!hOvr;
24862 var availH=Math.max(120,H-24);
24863 var rowsFit=Math.max(2,Math.floor(availH/18));
24864 var legTrunc=compact&&(n>2*rowsFit);
24865 var legShown=legTrunc?(2*rowsFit-1):n;
24866 var legTotal=legTrunc?(2*rowsFit):n;
24867 var legCols=legTotal>Math.min(rowsFit,18)?2:1;
24868 var legPerCol=Math.ceil(legTotal/legCols);
24869 var legRowH=Math.max(14,Math.min(30,Math.floor(availH/legPerCol)));
24870 var legColW=hOvr?144:130;
24871 var LG=26;
24872 var legW=legCols*legColW;
24873 var cW=W-PL-LG-legW;
24874 var legOrder=SCAT_D.map(function(_,i){return i;}).sort(function(a,b){return (SCAT_D[b].code||0)-(SCAT_D[a].code||0);});
24875 var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
24876 var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
24877 var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
24878 // log1p scale on X to prevent outlier files-count from collapsing all others to the left
24879 var logMaxF=Math.log1p(maxF);
24880 var s='<svg class="scat-svg" viewBox="0 0 '+W+' '+H+'" width="'+W+'" height="'+H+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
24881 // Smooth the legend-hover fade so bubbles + labels animate together.
24882 s+='<style>.scat-svg circle,.scat-svg text,.scat-svg g{transition:opacity .2s ease,filter .2s ease;}</style>';
24883 // Y grid lines (linear)
24884 [0,0.25,0.5,0.75,1].forEach(function(t){
24885 var y=PT+cH*(1-t);
24886 s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
24887 if(t>0)s+='<text x="'+(PL-4)+'" y="'+(px(y)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="currentColor" opacity="0.72">'+fmt(Math.round(maxC*t))+'</text>';
24888 });
24889 // X grid lines (log1p scale — tick labels show actual file counts at those positions)
24890 [0,0.25,0.5,0.75,1].forEach(function(t){
24891 var x=PL+cW*t;
24892 var xVal=t>0?Math.round(Math.expm1(t*logMaxF)):0;
24893 s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
24894 if(t>0)s+='<text x="'+px(x)+'" y="'+(PT+cH+15)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="currentColor" opacity="0.72">'+fmt(xVal)+'</text>';
24895 });
24896 // Full View (hOvr set) has the vertical room to show the per-bubble value
24897 // line; the compact card shows only the language label to avoid the
24898 // overlapping-label clutter seen when bubbles cluster together.
24899 var showVal=!!hOvr;
24900 SCAT_D.forEach(function(d,i){
24901 // X uses log1p so outlier languages (many files) don't push others to the far left
24902 var cx2=PL+(logMaxF>0?Math.log1p(Math.max(1,d.files))/logMaxF:0.5)*cW;
24903 var cy2=PT+cH-d.code/maxC*cH;
24904 var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
24905 s+='<circle'+tt(d.lang,fmt(d.files)+' files · '+fmt(d.code)+' code lines')+' data-lang="'+esc(d.lang)+'" cx="'+px(cx2)+'" cy="'+px(cy2)+'" r="'+px(r)+'" fill="'+COLS[i%COLS.length]+'" opacity="0.78" stroke="white" stroke-width="1.5"/>';
24906 // Label(s) centred directly above bubble; clamp to stay inside the plot top.
24907 if(showVal){
24908 var ty2=Math.max(24,px(cy2)-px(r)-3);
24909 var ty1=Math.max(12,ty2-14);
24910 s+='<text data-lang="'+esc(d.lang)+'" x="'+px(cx2)+'" y="'+ty1+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" font-weight="800" fill="currentColor" opacity="0.92" style="pointer-events:none;">'+esc(d.lang)+'</text>';
24911 s+='<text data-lang="'+esc(d.lang)+'" x="'+px(cx2)+'" y="'+ty2+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor" opacity="0.88" style="pointer-events:none;">'+fmt(d.code)+'</text>';
24912 }else{
24913 var ly2=Math.max(12,px(cy2)-px(r)-3);
24914 s+='<text data-lang="'+esc(d.lang)+'" x="'+px(cx2)+'" y="'+ly2+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" font-weight="800" fill="currentColor" opacity="0.92" style="pointer-events:none;">'+esc(d.lang)+'</text>';
24915 }
24916 });
24917 s+='<text x="'+(PL+cW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="currentColor" opacity="0.75">Files Analyzed</text>';
24918 s+='<text x="10" y="'+(PT+cH/2)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="currentColor" opacity="0.75" transform="rotate(-90,10,'+(PT+cH/2)+')">Code Lines</text>';
24919 // Legend (right side — top languages, max 2 columns, fills height)
24920 var legX=PL+cW+LG;
24921 var legBlockH=legPerCol*legRowH;
24922 var legY0=Math.max(8,Math.floor((H-legBlockH)/2));
24923 function legXY(k){return {x:legX+Math.floor(k/legPerCol)*legColW,y:legY0+(k%legPerCol)*legRowH};}
24924 for(var lk=0;lk<legShown;lk++){
24925 var oi=legOrder[lk],ld=SCAT_D[oi],lcol=COLS[oi%COLS.length];
24926 var lp=legXY(lk),ly=lp.y+Math.floor(legRowH/2);
24927 s+='<g data-lang="'+esc(ld.lang)+'" data-ttl="'+esc(ld.lang)+'" data-ttv="'+esc(fmt(ld.files)+' files · '+fmt(ld.code)+' code lines')+'" style="cursor:pointer;">';
24928 s+='<rect x="'+lp.x+'" y="'+lp.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
24929 s+='<rect x="'+lp.x+'" y="'+(ly-6)+'" width="22" height="12" rx="2" fill="'+lcol+'" opacity="0.88" style="pointer-events:none;"/>';
24930 s+='<text x="'+(lp.x+28)+'" y="'+(ly+4)+'" font-family="'+FONT+'" font-size="12" font-weight="400" fill="currentColor" style="pointer-events:none;">'+esc(ld.lang)+'</text>';
24931 s+='</g>';
24932 }
24933 if(legTrunc){
24934 var pm=legXY(legShown),lym=pm.y+Math.floor(legRowH/2);
24935 s+='<g data-more="1" style="cursor:pointer;">';
24936 s+='<rect x="'+pm.x+'" y="'+pm.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
24937 s+='<rect x="'+pm.x+'" y="'+(lym-6)+'" width="22" height="12" rx="2" fill="#9a8c82" opacity="0.45" style="pointer-events:none;"/>';
24938 s+='<text x="'+(pm.x+28)+'" y="'+(lym+4)+'" font-family="'+FONT+'" font-size="12" font-style="italic" fill="currentColor" opacity="0.8" style="pointer-events:none;">+'+(n-legShown)+' more</text>';
24939 s+='</g>';
24940 }
24941 s+='</svg>';
24942 el.innerHTML=s;
24943 wireScatterLegend(el);
24944 var moreEl=el.querySelector('g[data-more]');
24945 if(moreEl)moreEl.addEventListener('click',function(){var b=document.getElementById('r-scatter-expand');if(b)b.click();});
24946 }
24947 renderScatterInEl(document.getElementById('r-scatter-chart'),0);
24948
24949 // ── Semantic: horizontal bar chart (one bar per language) ─────────────
24950 // Horizontal layout avoids the portrait-aspect scaling bug that plagued
24951 // the old vertical column layout on wide containers.
24952 function renderSemanticInEl(el,key,sh){
24953 if(!el||!SEM_D||!SEM_D.length)return;
24954 var n2=SEM_D.length||1;
24955 var LW=112,SH=sh||Math.max(180,n2*28+26);
24956 var svgW=Math.max(320,el.offsetWidth||480);
24957 var BW=Math.max(120,svgW-LW-80);
24958 var topPad=4,botPad=14;
24959 var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
24960 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
24961 var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
24962 var s='<svg viewBox="0 0 '+svgW+' '+SH+'" width="'+svgW+'" height="'+SH+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
24963 SEM_D.forEach(function(d,i){
24964 var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
24965 s+='<text x="'+(LW-5)+'" y="'+(y+Math.floor(bH/2)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="currentColor">'+esc(d.lang)+'</text>';
24966 if(bw>0.5)s+='<rect'+tt(d.lang,fmt(v)+' '+key)+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+COLS[i%COLS.length]+'" rx="3"/>';
24967 s+='<text x="'+(LW+px(bw)+6)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor" style="pointer-events:none;">'+fmt(v)+'</text>';
24968 });
24969 s+='</svg>';
24970 el.innerHTML=s;
24971 }
24972 function renderSemantic(key){renderSemanticInEl(document.getElementById('r-semantic-chart'),key,0);}
24973 var semSel=document.getElementById('r-semantic-metric');
24974 if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);syncRowHeights();});}
24975 var semExpand=document.getElementById('r-semantic-expand');
24976 if(semExpand){
24977 semExpand.addEventListener('click',function(){
24978 var key=semSel?semSel.value:'functions';
24979 var n=SEM_D.length||1;
24980 var maxH=Math.max(360,Math.floor(window.innerHeight*0.82)-130);
24981 var modalH=Math.min(Math.max(360,n*38+60),maxH);
24982 var overlay=document.createElement('div');
24983 overlay.className='r-chart-modal-overlay';
24984 var optHtml=
24985 '<option value="functions"'+(key==='functions'?' selected':'')+'>Functions</option>'
24986 +'<option value="classes"'+(key==='classes'?' selected':'')+'>Classes</option>'
24987 +'<option value="variables"'+(key==='variables'?' selected':'')+'>Variables</option>'
24988 +'<option value="imports"'+(key==='imports'?' selected':'')+'>Imports</option>'
24989 +'<option value="tests"'+(key==='tests'?' selected':'')+'>Tests</option>';
24990 overlay.innerHTML='<div class="r-chart-modal" style="max-width:1320px;"><button class="r-chart-modal-close" aria-label="Close">×</button><div class="r-modal-header"><span class="r-chart-modal-title">Semantic Metrics \u2014 Full View</span><select class="r-chart-select" id="r-sem-modal-metric">'+optHtml+'</select></div><div id="r-sem-modal-chart" class="r-expand-modal-chart" style="height:'+modalH+'px;width:100%;overflow:hidden;"></div></div>';
24991 document.body.appendChild(overlay);
24992 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
24993 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
24994 var modalEl=document.getElementById('r-sem-modal-chart');
24995 if(modalEl){setTimeout(function(){renderSemanticInEl(modalEl,key,modalH);},30);}
24996 var modalSel=document.getElementById('r-sem-modal-metric');
24997 if(modalSel){modalSel.addEventListener('change',function(){renderSemanticInEl(modalEl,modalSel.value,modalH);});}
24998 });
24999 }
25000
25001 // ── Expand buttons: re-render charts at large size inside modal ──────────
25002 (function(){
25003 function makeExpandModal(title,mH,subtitle,ctrlHtml){
25004 var overlay=document.createElement('div');
25005 overlay.className='r-chart-modal-overlay';
25006 var subHtml=subtitle?'<span class="r-chart-modal-subtitle">'+subtitle+'</span>':'';
25007 var hdr='<div class="r-modal-header"><span class="r-chart-modal-title">'+title+' \u2014 Full View</span>'+(ctrlHtml||'')+'</div>';
25008 overlay.innerHTML='<div class="r-chart-modal" style="max-width:1320px;"><button class="r-chart-modal-close" aria-label="Close">×</button>'+hdr+subHtml+'<div class="r-expand-modal-chart" style="width:100%;height:'+mH+'px;overflow:hidden;"></div></div>';
25009 document.body.appendChild(overlay);
25010 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
25011 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
25012 return overlay.querySelector('.r-expand-modal-chart');
25013 }
25014 function capH(h){return Math.min(h,Math.max(360,Math.floor(window.innerHeight*0.82)-130));}
25015 var compExpandBtn=document.getElementById('r-composition-expand');
25016 if(compExpandBtn){compExpandBtn.addEventListener('click',function(){
25017 var mode=document.querySelector('[data-rcomp].active');var modeKey=mode?mode.getAttribute('data-rcomp'):'abs';
25018 var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
25019 var ctrlHtml='<button class="r-chart-tab'+(modeKey==='abs'?' active':'')+'" data-mcomp="abs">Absolute</button>'
25020 +'<button class="r-chart-tab'+(modeKey==='pct'?' active':'')+'" data-mcomp="pct">100% Normalized</button>';
25021 var wrap=makeExpandModal('Language Composition',mH,null,ctrlHtml);
25022 if(wrap){
25023 setTimeout(function(){renderCompositionInEl(wrap,modeKey,mH);},30);
25024 Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(btn){
25025 btn.addEventListener('click',function(){
25026 Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(b){b.classList.remove('active');});
25027 btn.classList.add('active');
25028 renderCompositionInEl(wrap,btn.getAttribute('data-mcomp'),mH);
25029 });
25030 });
25031 }
25032 });}
25033 var scatExpandBtn=document.getElementById('r-scatter-expand');
25034 if(scatExpandBtn){scatExpandBtn.addEventListener('click',function(){
25035 var wrap=makeExpandModal('Files vs Code Lines',capH(672),'File count vs SLOC per language');
25036 if(wrap)setTimeout(function(){renderScatterInEl(wrap,560);},30);
25037 });}
25038 var densExpandBtn=document.getElementById('r-density-expand');
25039 if(densExpandBtn){densExpandBtn.addEventListener('click',function(){
25040 var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
25041 var wrap=makeExpandModal('Comment Density',mH,'Comment ratio per language');
25042 if(wrap)setTimeout(function(){renderDensityInEl(wrap,mH);},30);
25043 });}
25044 var avgExpandBtn=document.getElementById('r-avglines-expand');
25045 if(avgExpandBtn){avgExpandBtn.addEventListener('click',function(){
25046 var n=LANG_D.filter(function(d){return(d.files||0)>0;}).length||1;var mH=capH(Math.max(360,n*38+60));
25047 var wrap=makeExpandModal('Avg Lines per File',mH,'Average code lines per file');
25048 if(wrap)setTimeout(function(){renderAvgLinesInEl(wrap,mH);},30);
25049 });}
25050 var subExpandBtn=document.getElementById('r-submodule-expand');
25051 if(subExpandBtn){subExpandBtn.addEventListener('click',function(){
25052 var key=subSel?subSel.value:'code';var sort=sortSel?sortSel.value:'desc';
25053 var n=(SUB_D.length+1)||1;var mH=capH(Math.max(360,n*32+100));
25054 var metCtrl=
25055 '<select class="r-chart-select" id="r-sub-modal-metric">'
25056 +'<option value="code"'+(key==='code'?' selected':'')+'>Code Lines</option>'
25057 +'<option value="comment"'+(key==='comment'?' selected':'')+'>Comments</option>'
25058 +'<option value="blank"'+(key==='blank'?' selected':'')+'>Blank Lines</option>'
25059 +'<option value="physical"'+(key==='physical'?' selected':'')+'>Physical Lines</option>'
25060 +'<option value="files"'+(key==='files'?' selected':'')+'>Files</option>'
25061 +'</select>';
25062 var sortCtrl=
25063 '<select class="r-chart-select" id="r-sub-modal-sort">'
25064 +'<option value="desc"'+(sort==='desc'?' selected':'')+'>Value \u2193</option>'
25065 +'<option value="asc"'+(sort==='asc'?' selected':'')+'>Value \u2191</option>'
25066 +'<option value="name"'+(sort==='name'?' selected':'')+'>Name A\u2192Z</option>'
25067 +'</select>';
25068 var wrap=makeExpandModal('Repository Overview',mH,null,metCtrl+sortCtrl);
25069 if(wrap){
25070 setTimeout(function(){renderSubmoduleInEl(wrap,key,sort,mH);},30);
25071 var mSub=wrap.parentNode.querySelector('#r-sub-modal-metric');
25072 var mSort=wrap.parentNode.querySelector('#r-sub-modal-sort');
25073 function reRenderSub(){renderSubmoduleInEl(wrap,mSub?mSub.value:'code',mSort?mSort.value:'desc',mH);}
25074 if(mSub)mSub.addEventListener('change',reRenderSub);
25075 if(mSort)mSort.addEventListener('change',reRenderSub);
25076 }
25077 });}
25078 })();
25079
25080 // ── Comment Density: comments / (code + comments) per language ───────────
25081 function renderDensityInEl(el,shOvr){
25082 if(!el||!LANG_D||!LANG_D.length)return;
25083 var n=LANG_D.length||1;
25084 var LW=112,SH=shOvr||Math.max(180,n*28+26);
25085 var svgW=Math.max(320,el.offsetWidth||480);
25086 var BW=Math.max(120,svgW-LW-80);
25087 var topPad=4,botPad=26;
25088 var rowTotal=Math.floor((SH-topPad-botPad)/n);
25089 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
25090 var densities=LANG_D.map(function(d){
25091 var sig=(d.code||0)+(d.comments||0);
25092 return sig>0?(d.comments||0)/sig:0;
25093 });
25094 var maxDen=Math.max.apply(null,densities)||1;
25095 var s='<svg viewBox="0 0 '+svgW+' '+SH+'" width="'+svgW+'" height="'+SH+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
25096 LANG_D.forEach(function(d,i){
25097 var den=densities[i],bw=den/maxDen*BW;
25098 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
25099 var pct=Math.round(den*100);
25100 s+='<text x="'+(LW-5)+'" y="'+(y+Math.floor(bH/2)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="currentColor">'+esc(d.lang)+'</text>';
25101 if(bw>0.5)s+='<rect'+tt(d.lang,pct+'% of significant lines are comments')+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+COLS[i%COLS.length]+'" rx="3"/>';
25102 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
25103 s+='<text x="'+(LW+Math.max(px(bw),2)+6)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor" style="pointer-events:none;">'+pct+'%</text>';
25104 });
25105 s+='<text x="'+(LW+BW/2)+'" y="'+(SH-6)+'" text-anchor="middle" font-family="'+FONT+'" font-size="12" fill="currentColor" opacity="0.75">comment ratio (higher = more documented)</text>';
25106 s+='</svg>';
25107 el.innerHTML=s;
25108 }
25109 function renderDensity(){renderDensityInEl(document.getElementById('r-density-chart'),0);}
25110 renderDensity();
25111
25112 // ── Avg Lines per File: code / files per language ─────────────────────
25113 function renderAvgLinesInEl(el,shOvr){
25114 if(!el||!LANG_D||!LANG_D.length)return;
25115 var data=LANG_D.filter(function(d){return(d.files||0)>0;}).slice();
25116 data.sort(function(a,b){return(b.code/b.files)-(a.code/a.files);});
25117 var n=data.length||1;
25118 var LW=112,SH=shOvr||Math.max(180,n*28+26);
25119 var svgW=Math.max(320,el.offsetWidth||480);
25120 var BW=Math.max(120,svgW-LW-80);
25121 var topPad=4,botPad=26;
25122 var rowTotal=Math.floor((SH-topPad-botPad)/n);
25123 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
25124 var avgs=data.map(function(d){return(d.code||0)/(d.files||1);});
25125 var maxAvg=Math.max.apply(null,avgs)||1;
25126 var s='<svg viewBox="0 0 '+svgW+' '+SH+'" width="'+svgW+'" height="'+SH+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
25127 data.forEach(function(d,i){
25128 var avg=avgs[i],bw=avg/maxAvg*BW;
25129 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
25130 s+='<text x="'+(LW-5)+'" y="'+(y+Math.floor(bH/2)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="currentColor">'+esc(d.lang)+'</text>';
25131 if(bw>0.5)s+='<rect'+tt(d.lang,fmt(Math.round(avg))+' avg code lines/file \u00b7 '+fmt(d.files||0)+' files')+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+COLS[i%COLS.length]+'" rx="3"/>';
25132 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
25133 s+='<text x="'+(LW+Math.max(px(bw),2)+6)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor" style="pointer-events:none;">'+fmt(Math.round(avg))+'</text>';
25134 });
25135 s+='<text x="'+(LW+BW/2)+'" y="'+(SH-6)+'" text-anchor="middle" font-family="'+FONT+'" font-size="12" fill="currentColor" opacity="0.75">avg code lines per file (higher = larger files)</text>';
25136 s+='</svg>';
25137 el.innerHTML=s;
25138 }
25139 function renderAvgLines(){renderAvgLinesInEl(document.getElementById('r-avglines-chart'),0);}
25140 renderAvgLines();
25141
25142 // ── Repository Overview: overall row + per-submodule rows ────────────
25143 function renderSubmoduleInEl(el,key,sort,shOvr){
25144 if(!el)return;
25145 var overall={
25146 name:'Overall',
25147 code:{{ code_lines }},
25148 comment:{{ comment_lines }},
25149 blank:{{ blank_lines }},
25150 physical:{{ physical_lines }},
25151 files:{{ files_analyzed }},
25152 isOverall:true
25153 };
25154 var subs=SUB_D.slice();
25155 if(sort==='desc')subs.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
25156 else if(sort==='asc')subs.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
25157 else subs.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
25158 var data=[overall].concat(subs);
25159 var sepH=subs.length>0?14:0;
25160 var naturalH=data.length*32+sepH+16;
25161 var SH=shOvr||Math.max(100,naturalH);
25162 var svgW=Math.max(320,el.offsetWidth||480);
25163 var LW=116,BW=Math.max(200,svgW-LW-54);
25164 var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
25165 var OVERALL_COL='#6b7280';
25166 var topPad=4,botPad=8;
25167 var rowSlot=Math.floor((SH-topPad-botPad-sepH)/data.length);
25168 var bH=Math.min(22,Math.max(10,Math.floor(rowSlot*0.65)));
25169 var s='<svg viewBox="0 0 '+svgW+' '+SH+'" width="'+svgW+'" height="'+SH+'" style="display:block;max-width:100%;" xmlns="http://www.w3.org/2000/svg">';
25170 var yOff=topPad;
25171 data.forEach(function(d,i){
25172 var v=d[key]||0,bw=v/maxV*BW;
25173 var y=yOff+Math.floor((rowSlot-bH)/2);
25174 var col=d.isOverall?OVERALL_COL:COLS[(i-1)%COLS.length];
25175 var label=d.name||d.path||'?';
25176 s+='<text x="'+(LW-5)+'" y="'+(y+Math.floor(bH/2)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="currentColor"'+(d.isOverall?' font-weight="700"':'')+'>'+esc(label)+'</text>';
25177 if(bw>0.5)s+='<rect'+tt(label,fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+col+'" rx="3"/>';
25178 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
25179 s+='<text x="'+(LW+Math.max(px(bw),2)+6)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor" style="pointer-events:none;">'+fmt(v)+'</text>';
25180 yOff+=rowSlot;
25181 if(d.isOverall&&subs.length>0){
25182 yOff+=sepH;
25183 }
25184 });
25185 s+='</svg>';
25186 el.innerHTML=s;
25187 }
25188 function renderSubmodule(key,sort){renderSubmoduleInEl(document.getElementById('r-submodule-chart'),key,sort,0);}
25189 var subSel=document.getElementById('r-sub-metric');
25190 var sortSel=document.getElementById('r-sub-sort');
25191 renderSubmodule('code','desc');
25192 if(subSel){
25193 subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');syncRowHeights();});
25194 if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);syncRowHeights();});
25195 }
25196
25197 // Equalise heights within each chart row: if one chart in a grid row is taller
25198 // than its neighbour, re-render the shorter one at the taller height so bars fill
25199 // the available vertical space instead of leaving a gap.
25200 function syncRowHeights(){
25201 var avgEl=document.getElementById('r-avglines-chart');
25202 var subEl=document.getElementById('r-submodule-chart');
25203 if(avgEl&&subEl){
25204 var avgSvg=avgEl.querySelector('svg');
25205 var subSvg=subEl.querySelector('svg');
25206 if(avgSvg&&subSvg){
25207 var avgH=parseInt(avgSvg.getAttribute('height')||'0',10);
25208 var subH=parseInt(subSvg.getAttribute('height')||'0',10);
25209 var key=subSel?subSel.value||'code':'code';
25210 var sort=sortSel?sortSel.value:'desc';
25211 if(subH>avgH+10){renderAvgLinesInEl(avgEl,subH);}
25212 else if(avgH>subH+10){renderSubmoduleInEl(subEl,key,sort,avgH);}
25213 }
25214 }
25215 var semEl=document.getElementById('r-semantic-chart');
25216 var denEl=document.getElementById('r-density-chart');
25217 if(semEl&&denEl){
25218 var semSvg=semEl.querySelector('svg');
25219 var denSvg=denEl.querySelector('svg');
25220 if(semSvg&&denSvg){
25221 var semH2=parseInt(semSvg.getAttribute('height')||'0',10);
25222 var denH2=parseInt(denSvg.getAttribute('height')||'0',10);
25223 if(denH2>semH2+10){renderSemanticInEl(semEl,semSel?semSel.value:'functions',denH2);}
25224 else if(semH2>denH2+10){renderDensityInEl(denEl,semH2);}
25225 }
25226 }
25227 }
25228 syncRowHeights();
25229
25230 // Re-render all SVG charts when the window is resized so bars fill the card.
25231 var _rResizeTimer;
25232 window.addEventListener('resize',function(){
25233 clearTimeout(_rResizeTimer);
25234 _rResizeTimer=setTimeout(function(){
25235 var rcompBtn=document.querySelector('[data-rcomp].active');
25236 renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
25237 renderScatterInEl(document.getElementById('r-scatter-chart'),0);
25238 if(semSel)renderSemantic(semSel.value||'functions');
25239 renderDensity();
25240 renderAvgLines();
25241 renderSubmodule(subSel?subSel.value||'code':'code',sortSel?sortSel.value:'desc');
25242 syncRowHeights();
25243 },120);
25244 });
25245 })();
25246
25247 (function randomizeWatermarks() {
25248 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
25249 if (!wms.length) return;
25250 var placed = [];
25251 function tooClose(top, left) {
25252 for (var i = 0; i < placed.length; i++) {
25253 var dt = Math.abs(placed[i][0] - top);
25254 var dl = Math.abs(placed[i][1] - left);
25255 if (dt < 20 && dl < 18) return true;
25256 }
25257 return false;
25258 }
25259 function pick(leftBand) {
25260 for (var attempt = 0; attempt < 50; attempt++) {
25261 var top = Math.random() * 85 + 5;
25262 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
25263 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
25264 }
25265 var top = Math.random() * 85 + 5;
25266 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
25267 placed.push([top, left]);
25268 return [top, left];
25269 }
25270 var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
25271 var half = Math.floor(wms.length / 2);
25272 wms.forEach(function (img, i) {
25273 var pos = pick(i < half);
25274 var size = Math.floor(Math.random() * 100 + 160);
25275 var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
25276 var op = (Math.random() * 0.06 + 0.07).toFixed(2);
25277 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;
25278 });
25279 })();
25280
25281 (function spawnCodeParticles() {
25282 var container = document.getElementById('code-particles');
25283 if (!container) return;
25284 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'];
25285 for (var i = 0; i < 38; i++) {
25286 (function(idx) {
25287 var el = document.createElement('span');
25288 el.className = 'code-particle';
25289 el.textContent = snippets[idx % snippets.length];
25290 var left = Math.random() * 94 + 2;
25291 var top = Math.random() * 88 + 6;
25292 var dur = (Math.random() * 10 + 9).toFixed(1);
25293 var delay = (Math.random() * 18).toFixed(1);
25294 var rot = (Math.random() * 26 - 13).toFixed(1);
25295 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
25296 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';
25297 container.appendChild(el);
25298 })(i);
25299 }
25300 })();
25301
25302 {% if pdf_generating %}
25303 // Poll for PDF readiness and swap the disabled button to a live link once done.
25304 (function() {
25305 var openBtn = document.getElementById('pdf-open-btn');
25306 var dlBtn = document.getElementById('pdf-download-btn');
25307 function checkPdf() {
25308 fetch('/api/runs/{{ run_id }}/pdf-status')
25309 .then(function(r) { return r.json(); })
25310 .then(function(d) {
25311 if (d.ready) {
25312 if (openBtn) {
25313 var a = document.createElement('a');
25314 a.className = 'button';
25315 a.id = 'pdf-open-btn';
25316 a.href = '/runs/pdf/{{ run_id }}';
25317 a.target = '_blank';
25318 a.rel = 'noopener';
25319 a.textContent = 'Open PDF';
25320 openBtn.replaceWith(a);
25321 }
25322 if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
25323 } else {
25324 setTimeout(checkPdf, 3000);
25325 }
25326 })
25327 .catch(function() { setTimeout(checkPdf, 5000); });
25328 }
25329 setTimeout(checkPdf, 3000);
25330 })();
25331 {% endif %}
25332
25333 })();
25334 </script>
25335 <script nonce="{{ csp_nonce }}">
25336 (function(){
25337 var S=[{n:'Classic',a:'#b85d33',b:'#7a371b'},{n:'Navy',a:'#283790',b:'#1e1e24'},{n:'Ember',a:'#ce5d3d',b:'#1e1e24'},{n:'Ocean',a:'#1f439b',b:'#1e1e24'},{n:'Royal',a:'#003184',b:'#1e1e24'}];
25338 function ap(s){document.documentElement.style.setProperty('--nav',s.a);document.documentElement.style.setProperty('--nav-2',s.b);try{localStorage.setItem('sloc-ns',JSON.stringify(s));}catch(e){}document.querySelectorAll('.scheme-swatch').forEach(function(x){x.classList.toggle('active',x.dataset.n===s.n);});}
25339 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25340 function init(){
25341 var btn=document.getElementById('settings-btn');if(!btn)return;
25342 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25343 m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
25344 document.body.appendChild(m);
25345 var g=document.getElementById('scheme-grid');
25346 if(g)S.forEach(function(s){var el=document.createElement('button');el.type='button';el.className='scheme-swatch';el.dataset.n=s.n;el.title=s.n;var p=document.createElement('div');p.className='scheme-preview';p.style.background='linear-gradient(135deg,'+s.a+','+s.b+')';var l=document.createElement('span');l.className='scheme-label';l.textContent=s.n;el.appendChild(p);el.appendChild(l);try{var c=JSON.parse(localStorage.getItem('sloc-ns'));if(c&&c.n===s.n)el.classList.add('active');}catch(e){}el.addEventListener('click',function(){ap(s);});g.appendChild(el);});
25347 var cl=document.getElementById('settings-close');
25348 window.tzAbbr=function(z){return{'America/Los_Angeles':'PT','America/Denver':'MT','America/Chicago':'CT','America/New_York':'ET','America/Anchorage':'AT','Pacific/Honolulu':'HT'}[z]||'PT';};window.tzCity=function(z){return{'America/Los_Angeles':'Los Angeles','America/Denver':'Denver','America/Chicago':'Chicago','America/New_York':'New York','America/Anchorage':'Anchorage','Pacific/Honolulu':'Honolulu'}[z]||'';};window.tzOffset=function(z){var r='';try{var p=new Intl.DateTimeFormat('en-US',{timeZone:z,timeZoneName:'longOffset'}).formatToParts(new Date());p.forEach(function(x){if(x.type==='timeZoneName')r=x.value.replace('GMT','UTC');});}catch(e){}return r;};window.tf24=function(){try{return localStorage.getItem('sloc-tf')!=='12';}catch(e){return true;}};window.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';var h24=window.tf24();try{var pts=new Intl.DateTimeFormat('en-US',{timeZone:tz,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:!h24}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});var t=v.hour+':'+v.minute;if(!h24&&v.dayPeriod)t+=' '+v.dayPeriod;return v.year+'-'+v.month+'-'+v.day+' '+t+' '+window.tzAbbr(tz);}catch(e){return'';}};window.enhanceTzOptions=function(sel){if(!sel)return;Array.prototype.forEach.call(sel.options,function(o){var base=o.textContent.split(' - ')[0];var city=window.tzCity(o.value);var off=window.tzOffset(o.value);o.textContent=base+(city?' - '+city:'')+(off?' - '+off:'');});};window.applyTz=function(tz){try{localStorage.setItem('sloc-tz',tz);}catch(e){}document.querySelectorAll('[data-utc-ms]').forEach(function(el){var ms=parseInt(el.getAttribute('data-utc-ms'),10);if(!isNaN(ms))el.textContent=window.fmtTz(ms,tz);});};window.applyTf=function(tf){try{localStorage.setItem('sloc-tf',tf);}catch(e){}var z;try{z=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){z='America/Los_Angeles';}window.applyTz(z);};var tzSel=document.getElementById('tz-select');window.enhanceTzOptions(tzSel);var storedTz;try{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){storedTz='America/Los_Angeles';}if(tzSel){tzSel.value=storedTz;tzSel.addEventListener('change',function(){window.applyTz(this.value);});}window.applyTz(storedTz);(function(){var tzp=document.getElementById('tz-select');if(!tzp||document.getElementById('tf-select')||!tzp.parentNode)return;var tw=document.createElement('div');tw.style.marginTop='10px';var tl=document.createElement('div');tl.className='settings-modal-label';tl.style.marginBottom='8px';tl.textContent='Time format';var tfSel=document.createElement('select');tfSel.className='tz-select';tfSel.id='tf-select';tfSel.innerHTML='<option value="24">24-hour (14:30)</option><option value="12">12-hour (2:30 PM)</option>';tw.appendChild(tl);tw.appendChild(tfSel);tzp.parentNode.appendChild(tw);var storedTf;try{storedTf=localStorage.getItem('sloc-tf')||'24';}catch(e){storedTf='24';}tfSel.value=storedTf;tfSel.addEventListener('change',function(){window.applyTf(this.value);});})();
25349 btn.addEventListener('click',function(e){e.stopPropagation();var r=btn.getBoundingClientRect();m.style.top=(r.bottom+6)+'px';m.style.right=(window.innerWidth-r.right)+'px';m.classList.toggle('open');});
25350 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25351 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25352 }
25353 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25354 }());
25355 </script>
25356 <footer class="site-footer">
25357 local code analysis - metrics, history and reports
25358 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
25359 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25360 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25361 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25362 · <a href="/api-docs" rel="noopener">REST API</a>
25363 </footer>
25364 {% if confluence_configured %}
25365 <script nonce="{{ csp_nonce }}">
25366 (function() {
25367 var postBtn = document.getElementById('postConfluenceBtn');
25368 var copyBtn = document.getElementById('copyWikiBtn');
25369 var modal = document.getElementById('confluenceModal');
25370 if (!postBtn || !modal) return;
25371
25372 postBtn.addEventListener('click', function() {
25373 document.getElementById('confStatus').style.display = 'none';
25374 modal.style.display = 'flex';
25375 });
25376 document.getElementById('confCancelBtn').addEventListener('click', function() {
25377 modal.style.display = 'none';
25378 });
25379 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
25380
25381 document.getElementById('confSubmitBtn').addEventListener('click', async function() {
25382 var btn = this;
25383 btn.disabled = true;
25384 var status = document.getElementById('confStatus');
25385 status.style.display = 'block';
25386 status.style.background = '#dbeafe';
25387 status.style.color = '#1e40af';
25388 status.textContent = 'Posting to Confluence\u2026';
25389 var resp = await fetch('/api/confluence/post', {
25390 method: 'POST',
25391 headers: { 'Content-Type': 'application/json' },
25392 body: JSON.stringify({
25393 run_id: '{{ run_id }}',
25394 page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
25395 report_url: document.getElementById('confReportUrl').value.trim() || null
25396 })
25397 });
25398 var data = await resp.json();
25399 if (data.ok) {
25400 status.style.background = '#dcfce7'; status.style.color = '#166534';
25401 status.textContent = 'Posted! Page ID: ' + data.page_id;
25402 } else {
25403 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
25404 status.textContent = 'Error: ' + (data.error || 'Unknown error');
25405 }
25406 btn.disabled = false;
25407 });
25408
25409 if (copyBtn) {
25410 copyBtn.addEventListener('click', async function() {
25411 var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
25412 if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
25413 var text = await resp.text();
25414 try {
25415 await navigator.clipboard.writeText(text);
25416 var orig = copyBtn.textContent;
25417 copyBtn.textContent = 'Copied!';
25418 setTimeout(function() { copyBtn.textContent = orig; }, 2000);
25419 } catch(e) {
25420 alert('Clipboard write failed \u2014 check browser permissions.');
25421 }
25422 });
25423 }
25424 })();
25425 </script>
25426 {% endif %}
25427 <script nonce="{{ csp_nonce }}">
25428 (function() {
25429 var deleteBtn = document.getElementById('delete-run-btn');
25430 var modal = document.getElementById('delete-run-modal');
25431 var cancelBtn = document.getElementById('delete-run-cancel');
25432 var confirmBtn= document.getElementById('delete-run-confirm');
25433 if (!deleteBtn || !modal) return;
25434 deleteBtn.addEventListener('click', function() {
25435 document.getElementById('delete-run-status').style.display = 'none';
25436 modal.style.display = 'flex';
25437 });
25438 cancelBtn.addEventListener('click', function() { modal.style.display = 'none'; });
25439 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
25440 confirmBtn.addEventListener('click', async function() {
25441 confirmBtn.disabled = true;
25442 cancelBtn.disabled = true;
25443 var status = document.getElementById('delete-run-status');
25444 status.style.display = 'block';
25445 status.style.background = '#dbeafe'; status.style.color = '#1e40af';
25446 status.textContent = 'Deleting\u2026';
25447 try {
25448 var resp = await fetch('/api/runs/{{ run_id }}', { method: 'DELETE' });
25449 if (resp.status === 204 || resp.ok) {
25450 status.style.background = '#dcfce7'; status.style.color = '#166534';
25451 status.textContent = 'Deleted. Redirecting\u2026';
25452 setTimeout(function() { window.location.href = '/view-reports'; }, 1200);
25453 } else {
25454 var d = await resp.json().catch(function(){return {};});
25455 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
25456 status.textContent = 'Error: ' + (d.error || 'Unexpected server error');
25457 confirmBtn.disabled = false;
25458 cancelBtn.disabled = false;
25459 }
25460 } catch (e) {
25461 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
25462 status.textContent = 'Network error: ' + String(e);
25463 confirmBtn.disabled = false;
25464 cancelBtn.disabled = false;
25465 }
25466 });
25467 })();
25468 </script>
25469 <script nonce="{{ csp_nonce }}">(function(){
25470 var bundleBtn = document.getElementById('download-bundle-btn');
25471 if (bundleBtn) {
25472 bundleBtn.addEventListener('click', function() {
25473 bundleBtn.disabled = true;
25474 var orig = bundleBtn.textContent;
25475 bundleBtn.textContent = 'Preparing\u2026';
25476 fetch('/api/runs/{{ run_id }}/bundle')
25477 .then(function(r) {
25478 if (!r.ok) throw new Error('HTTP ' + r.status);
25479 return r.blob();
25480 })
25481 .then(function(blob) {
25482 var url = URL.createObjectURL(blob);
25483 var a = document.createElement('a');
25484 a.href = url;
25485 a.download = 'oxide-sloc-{{ run_id }}.tar.gz';
25486 document.body.appendChild(a);
25487 a.click();
25488 setTimeout(function() { URL.revokeObjectURL(url); document.body.removeChild(a); }, 5000);
25489 bundleBtn.disabled = false;
25490 bundleBtn.textContent = orig;
25491 })
25492 .catch(function(e) {
25493 bundleBtn.disabled = false;
25494 bundleBtn.textContent = orig;
25495 alert('Bundle download failed: ' + String(e));
25496 });
25497 });
25498 }
25499 })();</script>
25500 <script nonce="{{ csp_nonce }}">(function(){
25501 var dot=document.getElementById('status-dot');
25502 var pingEl=document.getElementById('server-ping-ms');
25503 var tipEl=document.getElementById('server-tip-ping');
25504 var fm=document.getElementById('footer-mode');
25505 function setDotColor(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}
25506 function doPing(){
25507 var t0=performance.now();
25508 fetch('/healthz',{cache:'no-store'})
25509 .then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDotColor(ms);})
25510 .catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});
25511 }
25512 doPing();
25513 setInterval(doPing,5000);
25514 if(fm){var isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';fm.textContent='oxide-sloc v{{ version }} \u2014 Mode: '+(isServer?'Network Server':'Local');}
25515 })();</script>
25516 <script nonce="{{ csp_nonce }}">(function(){var s=document.querySelector('.summary-strip-hero');if(!s)return;var pad=s.querySelector('.stat-chip-pad');var real=Array.prototype.slice.call(s.querySelectorAll('.stat-chip')).filter(function(el){return el!==pad;});if(!real.length)return;function upd(){var n=real.length;if(pad){if(n%2===1){pad.style.display='';n++;}else{pad.style.display='none';}}var perRow=window.innerWidth<=640?2:Math.ceil(n/2);s.style.gridTemplateColumns='repeat('+perRow+',minmax(0,1fr))';}upd();window.addEventListener('resize',upd);})();</script>
25517 {% if let Some(banner) = report_header_footer %}
25518 <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
25519 {% endif %}
25520</body>
25521</html>
25522"##,
25523 ext = "html"
25524)]
25525#[allow(clippy::struct_excessive_bools)]
25527struct ResultTemplate {
25528 version: &'static str,
25529 report_title: String,
25530 project_path: String,
25531 output_dir: String,
25532 run_id: String,
25533 files_analyzed: u64,
25534 files_skipped: u64,
25535 physical_lines: u64,
25536 code_lines: u64,
25537 comment_lines: u64,
25538 blank_lines: u64,
25539 mixed_lines: u64,
25540 functions: u64,
25541 classes: u64,
25542 variables: u64,
25543 imports: u64,
25544 html_url: Option<String>,
25545 pdf_url: Option<String>,
25546 json_url: Option<String>,
25547 html_download_url: Option<String>,
25548 pdf_download_url: Option<String>,
25549 json_download_url: Option<String>,
25550 html_path: Option<String>,
25551 json_path: Option<String>,
25552 prev_run_id: Option<String>,
25553 prev_run_timestamp: Option<String>,
25554 prev_run_code_lines: Option<u64>,
25555 prev_fa_str: String,
25557 prev_fs_str: String,
25558 prev_pl_str: String,
25559 prev_cl_str: String,
25560 prev_cml_str: String,
25561 prev_bl_str: String,
25562 delta_fa_str: String,
25564 delta_fa_class: String,
25565 delta_fs_str: String,
25566 delta_fs_class: String,
25567 delta_pl_str: String,
25568 delta_pl_class: String,
25569 delta_cl_str: String,
25570 delta_cl_class: String,
25571 delta_cml_str: String,
25572 delta_cml_class: String,
25573 delta_bl_str: String,
25574 delta_bl_class: String,
25575 delta_lines_added: Option<i64>,
25577 delta_lines_removed: Option<i64>,
25578 delta_lines_net_str: String,
25579 delta_lines_net_class: String,
25580 delta_files_added: Option<usize>,
25581 delta_files_removed: Option<usize>,
25582 delta_files_modified: Option<usize>,
25583 delta_files_unchanged: Option<usize>,
25584 delta_unmodified_lines: Option<u64>,
25585 git_branch: Option<String>,
25587 git_branch_url: Option<String>,
25588 git_commit: Option<String>,
25589 git_commit_long: Option<String>,
25590 git_author: Option<String>,
25591 git_commit_url: Option<String>,
25592 scan_performed_by: String,
25594 scan_time_display: String,
25595 scan_time_utc_ms: i64,
25596 os_display: String,
25597 test_count: u64,
25598 test_assertion_count: u64,
25600 prev_scan_count: usize,
25602 current_scan_number: usize,
25603 submodule_rows: Vec<SubmoduleRow>,
25605 scan_config_url: String,
25606 lang_chart_json: String,
25607 #[allow(dead_code)]
25609 scatter_chart_json: String,
25610 #[allow(dead_code)]
25611 semantic_chart_json: String,
25612 #[allow(dead_code)]
25613 submodule_chart_json: String,
25614 #[allow(dead_code)]
25615 has_submodule_data: bool,
25616 #[allow(dead_code)]
25617 has_semantic_data: bool,
25618 pdf_generating: bool,
25619 csp_nonce: String,
25620 confluence_configured: bool,
25622 server_mode: bool,
25623 report_header_footer: Option<String>,
25625 run_id_short: String,
25626 #[allow(dead_code)]
25628 is_offline: bool,
25629 cyclomatic_complexity: u64,
25631 lsloc: Option<u64>,
25633 uloc: u64,
25635 dryness_pct_str: String,
25637 duplicate_group_count: usize,
25639 has_cocomo: bool,
25641 cocomo_effort_str: String,
25643 cocomo_duration_str: String,
25645 cocomo_staff_str: String,
25647 cocomo_ksloc_str: String,
25649 cocomo_mode_label: String,
25651 cocomo_mode_tooltip: String,
25653 complexity_alert: u32,
25655 has_coverage_data: bool,
25657 cov_line_pct: String,
25659 cov_fn_pct: String,
25661 cov_branch_pct: String,
25663 cov_lines_summary: String,
25665}
25666
25667#[derive(Template)]
25668#[template(
25669 source = r##"
25670<!doctype html>
25671<html lang="en">
25672<head>
25673 <meta charset="utf-8">
25674 <meta name="viewport" content="width=device-width, initial-scale=1">
25675 <title>OxideSLOC | Analyzing…</title>
25676 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
25677 <style nonce="{{ csp_nonce }}">
25678 :root {
25679 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
25680 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
25681 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
25682 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
25683 }
25684 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
25685 *{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);} body{display:flex;flex-direction:column;}
25686 .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);}
25687 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
25688 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
25689 .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));}
25690 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
25691 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
25692 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
25693 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
25694 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
25695 @media (max-width: 1150px) { .nav-right { gap: 4px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 8px; font-size: 11px; min-height: 34px; } .brand-subtitle { display: none; } .server-online-pill { width: 34px; padding: 0; justify-content: center; font-size: 0; gap: 0; min-height: 34px; } }
25696 .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;}
25697 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
25698 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
25699 .page-body{padding:32px 24px 36px;}
25700 .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
25701 .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;}
25702 .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
25703 @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
25704 .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
25705 .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
25706 .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;}
25707 .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
25708 .metric-card{background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:12px 18px;min-width:140px;flex:1;text-align:center;}
25709 .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
25710 .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
25711 .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
25712 .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;}
25713 @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
25714 .hidden{display:none!important;}
25715 .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;}
25716 .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;}
25717 .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
25718 .err-panel p{margin:0;font-size:13px;color:var(--muted);}
25719 .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
25720 .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);}
25721 .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
25722 .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;}
25723 .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
25724 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25725 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
25726 @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
25727 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25728 .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;}
25729 @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));}}
25730 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
25731 .site-footer a{color:var(--muted);}
25732 .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;}
25733 .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
25734 body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
25735 body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
25736 </style>
25737</head>
25738<body>
25739 <div class="background-watermarks" aria-hidden="true">
25740 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25741 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25742 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25743 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25744 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25745 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25746 </div>
25747 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
25748 <nav class="top-nav">
25749 <div class="top-nav-inner">
25750 <a href="/" class="brand">
25751 <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
25752 <div class="brand-copy">
25753 <h1 class="brand-title">OxideSLOC</h1>
25754 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
25755 </div>
25756 </a>
25757 <div class="nav-right">
25758 <a class="nav-pill" href="/">Home</a>
25759 <div class="nav-dropdown">
25760 <a href="/view-reports" class="nav-dropdown-btn">View Reports <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></a>
25761 <div class="nav-dropdown-menu">
25762 <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
25763 </div>
25764 </div>
25765 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
25766 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
25767 <div class="nav-dropdown">
25768 <a href="/git-browser" class="nav-dropdown-btn">Git Browser <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></a>
25769 <div class="nav-dropdown-menu">
25770 <a href="/integrations"><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>Integrations</a>
25771 </div>
25772 </div>
25773 <div class="server-status-wrap" id="server-status-wrap">
25774 <div class="nav-pill server-online-pill" id="server-status-pill">
25775 <span class="status-dot" id="status-dot"></span>
25776 <span id="server-status-label">Server</span>
25777 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
25778 </div>
25779 <div class="server-status-tip">
25780 OxideSLOC is running — accessible on your network.
25781 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
25782 </div>
25783 </div>
25784 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
25785 <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
25786 </button>
25787 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
25788 <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>
25789 <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>
25790 </button>
25791 </div>
25792 </div>
25793 </nav>
25794 <div class="page-body">
25795 <div class="wait-panel">
25796 <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
25797 <h2 class="wait-title">Analyzing your project…</h2>
25798 <p class="wait-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
25799 <div class="path-block">{{ project_path }}</div>
25800 <div class="metrics-row">
25801 <div class="metric-card">
25802 <div class="metric-label">Elapsed</div>
25803 <div class="metric-value" id="elapsed">0s</div>
25804 </div>
25805 <div class="metric-card">
25806 <div class="metric-label">Phase</div>
25807 <div class="metric-value" id="phase">Starting</div>
25808 </div>
25809 <div class="metric-card hidden" id="files-card">
25810 <div class="metric-label">Files</div>
25811 <div class="metric-value" id="files-progress">0</div>
25812 </div>
25813 </div>
25814 <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
25815 <div class="warn-slow hidden" id="warn-slow">
25816 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.
25817 </div>
25818 <div class="err-panel hidden" id="err-panel">
25819 <strong>Analysis failed</strong>
25820 <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
25821 </div>
25822 <div class="actions hidden" id="actions">
25823 <a href="/scan" class="btn-primary">Try Again</a>
25824 <a href="/view-reports" class="btn-outline">View Reports</a>
25825 </div>
25826 </div>
25827 </div>
25828 <script nonce="{{ csp_nonce }}">
25829 (function() {
25830 var WAIT_ID = {{ wait_id_json|safe }};
25831 var startTime = Date.now();
25832 var pollInterval = 1500;
25833 var retries = 0;
25834 var maxRetries = 5;
25835 var warnShown = false;
25836
25837 function fmt(n){var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}
25838
25839 function elapsed() {
25840 return Math.floor((Date.now() - startTime) / 1000);
25841 }
25842
25843 function updateElapsed() {
25844 var s = elapsed();
25845 document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
25846 }
25847
25848 function setPhase(txt) {
25849 document.getElementById('phase').textContent = txt;
25850 }
25851
25852 var elapsedTimer = setInterval(updateElapsed, 1000);
25853
25854 function poll() {
25855 fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
25856 .then(function(r) {
25857 if (!r.ok) throw new Error('HTTP ' + r.status);
25858 return r.json();
25859 })
25860 .then(function(data) {
25861 retries = 0;
25862 if (data.state === 'complete') {
25863 clearInterval(elapsedTimer);
25864 setPhase('Done');
25865 window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
25866 } else if (data.state === 'failed') {
25867 clearInterval(elapsedTimer);
25868 setPhase('Failed');
25869 document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
25870 document.getElementById('err-panel').classList.remove('hidden');
25871 document.getElementById('actions').classList.remove('hidden');
25872 } else {
25873 // still running
25874 var s = elapsed();
25875 if (s > 90 && !warnShown) {
25876 warnShown = true;
25877 document.getElementById('warn-slow').classList.remove('hidden');
25878 }
25879 setPhase(data.phase || 'Running');
25880 var fd = data.files_done || 0, ft = data.files_total || 0;
25881 if (ft > 0) {
25882 var card = document.getElementById('files-card');
25883 if (card) card.classList.remove('hidden');
25884 var fp = document.getElementById('files-progress');
25885 if (fp) fp.textContent = fmt(fd) + ' / ' + fmt(ft);
25886 }
25887 setTimeout(poll, pollInterval);
25888 }
25889 })
25890 .catch(function(err) {
25891 retries++;
25892 if (retries >= maxRetries) {
25893 clearInterval(elapsedTimer);
25894 document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
25895 document.getElementById('err-panel').classList.remove('hidden');
25896 document.getElementById('actions').classList.remove('hidden');
25897 } else {
25898 // exponential back-off capped at 8s
25899 setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
25900 }
25901 });
25902 }
25903
25904 setTimeout(poll, pollInterval);
25905
25906 // If the browser restores this page from bfcache (Back after viewing results),
25907 // timers may be frozen; kick off a fresh poll so we either redirect or resume.
25908 window.addEventListener("pageshow", function(e) {
25909 if (e.persisted) { setTimeout(poll, 200); }
25910 });
25911 })();
25912 </script>
25913 <footer class="site-footer">
25914 local code analysis - metrics, history and reports
25915 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
25916 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25917 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25918 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25919 · <a href="/api-docs" rel="noopener">REST API</a>
25920 </footer>
25921 <script nonce="{{ csp_nonce }}">
25922 (function(){
25923 var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
25924 if(s==="dark")b.classList.add("dark-theme");
25925 var tt=document.getElementById("theme-toggle");
25926 if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
25927 })();
25928 (function spawnCodeParticles(){
25929 var c=document.getElementById('code-particles');if(!c)return;
25930 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'];
25931 for(var i=0;i<32;i++){(function(idx){
25932 var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
25933 var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
25934 var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
25935 var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
25936 el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
25937 el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
25938 c.appendChild(el);
25939 })(i);}
25940 })();
25941 (function randomizeWatermarks(){
25942 var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
25943 var placed=[];
25944 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;}
25945 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];}
25946 var half=Math.floor(wms.length/2);
25947 wms.forEach(function(img,i){
25948 var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
25949 var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
25950 var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
25951 img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
25952 img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
25953 img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
25954 });
25955 })();
25956 </script>
25957 <script nonce="{{ csp_nonce }}">
25958 (function(){
25959 var S=[{n:'Classic',a:'#b85d33',b:'#7a371b'},{n:'Navy',a:'#283790',b:'#1e1e24'},{n:'Ember',a:'#ce5d3d',b:'#1e1e24'},{n:'Ocean',a:'#1f439b',b:'#1e1e24'},{n:'Royal',a:'#003184',b:'#1e1e24'}];
25960 function ap(s){document.documentElement.style.setProperty('--nav',s.a);document.documentElement.style.setProperty('--nav-2',s.b);try{localStorage.setItem('sloc-ns',JSON.stringify(s));}catch(e){}document.querySelectorAll('.scheme-swatch').forEach(function(x){x.classList.toggle('active',x.dataset.n===s.n);});}
25961 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25962 function init(){
25963 var btn=document.getElementById('settings-btn');if(!btn)return;
25964 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25965 m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
25966 document.body.appendChild(m);
25967 var g=document.getElementById('scheme-grid');
25968 if(g)S.forEach(function(s){var el=document.createElement('button');el.type='button';el.className='scheme-swatch';el.dataset.n=s.n;el.title=s.n;var p=document.createElement('div');p.className='scheme-preview';p.style.background='linear-gradient(135deg,'+s.a+','+s.b+')';var l=document.createElement('span');l.className='scheme-label';l.textContent=s.n;el.appendChild(p);el.appendChild(l);try{var c=JSON.parse(localStorage.getItem('sloc-ns'));if(c&&c.n===s.n)el.classList.add('active');}catch(e){}el.addEventListener('click',function(){ap(s);});g.appendChild(el);});
25969 var cl=document.getElementById('settings-close');
25970 window.tzAbbr=function(z){return{'America/Los_Angeles':'PT','America/Denver':'MT','America/Chicago':'CT','America/New_York':'ET','America/Anchorage':'AT','Pacific/Honolulu':'HT'}[z]||'PT';};window.tzCity=function(z){return{'America/Los_Angeles':'Los Angeles','America/Denver':'Denver','America/Chicago':'Chicago','America/New_York':'New York','America/Anchorage':'Anchorage','Pacific/Honolulu':'Honolulu'}[z]||'';};window.tzOffset=function(z){var r='';try{var p=new Intl.DateTimeFormat('en-US',{timeZone:z,timeZoneName:'longOffset'}).formatToParts(new Date());p.forEach(function(x){if(x.type==='timeZoneName')r=x.value.replace('GMT','UTC');});}catch(e){}return r;};window.tf24=function(){try{return localStorage.getItem('sloc-tf')!=='12';}catch(e){return true;}};window.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';var h24=window.tf24();try{var pts=new Intl.DateTimeFormat('en-US',{timeZone:tz,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:!h24}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});var t=v.hour+':'+v.minute;if(!h24&&v.dayPeriod)t+=' '+v.dayPeriod;return v.year+'-'+v.month+'-'+v.day+' '+t+' '+window.tzAbbr(tz);}catch(e){return'';}};window.enhanceTzOptions=function(sel){if(!sel)return;Array.prototype.forEach.call(sel.options,function(o){var base=o.textContent.split(' - ')[0];var city=window.tzCity(o.value);var off=window.tzOffset(o.value);o.textContent=base+(city?' - '+city:'')+(off?' - '+off:'');});};window.applyTz=function(tz){try{localStorage.setItem('sloc-tz',tz);}catch(e){}document.querySelectorAll('[data-utc-ms]').forEach(function(el){var ms=parseInt(el.getAttribute('data-utc-ms'),10);if(!isNaN(ms))el.textContent=window.fmtTz(ms,tz);});};window.applyTf=function(tf){try{localStorage.setItem('sloc-tf',tf);}catch(e){}var z;try{z=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){z='America/Los_Angeles';}window.applyTz(z);};var tzSel=document.getElementById('tz-select');window.enhanceTzOptions(tzSel);var storedTz;try{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){storedTz='America/Los_Angeles';}if(tzSel){tzSel.value=storedTz;tzSel.addEventListener('change',function(){window.applyTz(this.value);});}window.applyTz(storedTz);(function(){var tzp=document.getElementById('tz-select');if(!tzp||document.getElementById('tf-select')||!tzp.parentNode)return;var tw=document.createElement('div');tw.style.marginTop='10px';var tl=document.createElement('div');tl.className='settings-modal-label';tl.style.marginBottom='8px';tl.textContent='Time format';var tfSel=document.createElement('select');tfSel.className='tz-select';tfSel.id='tf-select';tfSel.innerHTML='<option value="24">24-hour (14:30)</option><option value="12">12-hour (2:30 PM)</option>';tw.appendChild(tl);tw.appendChild(tfSel);tzp.parentNode.appendChild(tw);var storedTf;try{storedTf=localStorage.getItem('sloc-tf')||'24';}catch(e){storedTf='24';}tfSel.value=storedTf;tfSel.addEventListener('change',function(){window.applyTf(this.value);});})();
25971 btn.addEventListener('click',function(e){e.stopPropagation();var r=btn.getBoundingClientRect();m.style.top=(r.bottom+6)+'px';m.style.right=(window.innerWidth-r.right)+'px';m.classList.toggle('open');});
25972 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25973 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25974 }
25975 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25976 }());
25977 </script>
25978 <script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';
25979 if(location.protocol==='file:'){if(lbl)lbl.textContent='Offline';if(dot){dot.style.background='#888';dot.style.boxShadow='none';}if(pingEl)pingEl.textContent='';if(fm)fm.textContent='oxide-sloc v{{ version }} \u2014 Saved Report';var td=document.querySelector('.server-status-tip');if(td)td.textContent='Saved HTML report \u2014 server not connected.';return;}
25980 if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
25981</body>
25982</html>
25983"##,
25984 ext = "html"
25985)]
25986struct ScanWaitTemplate {
25987 version: &'static str,
25988 wait_id_json: String,
25989 project_path: String,
25990 csp_nonce: String,
25991}
25992
25993#[derive(Template)]
25994#[template(
25995 source = r##"
25996<!doctype html>
25997<html lang="en">
25998<head>
25999 <meta charset="utf-8">
26000 <meta name="viewport" content="width=device-width, initial-scale=1">
26001 <title>OxideSLOC | Error</title>
26002 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26003 <style nonce="{{ csp_nonce }}">
26004 :root {
26005 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
26006 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
26007 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
26008 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
26009 }
26010 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
26011 *{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);} body{display:flex;flex-direction:column;}
26012 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26013 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26014 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
26015 .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);}
26016 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26017 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;} .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));}
26018 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26019 .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;white-space:nowrap;}
26020 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26021 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
26022 @media (max-width: 1150px) { .nav-right { gap: 4px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 8px; font-size: 11px; min-height: 34px; } .brand-subtitle { display: none; } .server-online-pill { width: 34px; padding: 0; justify-content: center; font-size: 0; gap: 0; min-height: 34px; } }
26023 .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;}
26024 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26025 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26026 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26027 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26028 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
26029 .settings-modal{position:fixed;z-index:9999;background:var(--surface-2);border:1px solid var(--line-strong);border-radius:14px;box-shadow:0 12px 36px rgba(0,0,0,0.22);min-width:260px;max-width:320px;opacity:0;pointer-events:none;transform:translateY(-8px) scale(0.97);transition:opacity 0.18s ease,transform 0.18s ease;overflow:hidden;}
26030 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26031 .settings-modal-header{display:flex;align-items:center;justify-content:space-between;padding:14px 16px 10px;border-bottom:1px solid var(--line);font-size:13px;font-weight:800;color:var(--text);}
26032 .settings-close{background:none;border:none;cursor:pointer;width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--muted);border-radius:6px;padding:0;}
26033 .settings-close:hover{color:var(--text);background:var(--surface-2);}
26034 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26035 .settings-modal-body{padding:14px 16px 16px;}
26036 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26037 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26038 .scheme-swatch{display:flex;flex-direction:column;align-items:center;gap:5px;background:none;border:1.5px solid var(--line);border-radius:10px;cursor:pointer;padding:7px 4px 6px;transition:border-color 0.15s ease,transform 0.12s ease;}
26039 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26040 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26041 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26042 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26043 .tz-select{width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2);color:var(--text);font-size:12px;font-weight:600;cursor:pointer;outline:none;box-sizing:border-box;}
26044 .tz-select:focus{border-color:var(--oxide);}
26045 .page{width:100%;max-width:1720px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
26046 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
26047 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
26048 h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
26049 .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;}
26050 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
26051 .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);}
26052 .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;}
26053 .btn-secondary:hover{background:var(--line);}
26054 .bug-report-section{margin-top:28px;padding-top:22px;border-top:1px solid var(--line);}
26055 .bug-report-trigger{display:inline-flex;align-items:center;gap:10px;padding:11px 22px;border-radius:14px;border:2px solid var(--oxide);background:transparent;color:var(--oxide);font-size:14px;font-weight:700;cursor:pointer;transition:background .18s ease,color .18s ease,box-shadow .18s ease;letter-spacing:.02em;}
26056 .bug-report-trigger:hover,.bug-report-trigger:focus-visible{background:var(--oxide);color:#fff;box-shadow:0 4px 20px rgba(174,92,32,.28);outline:none;}
26057 .bug-report-trigger .br-icon{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;flex-shrink:0;}
26058 .bug-report-trigger .br-chevron{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;transition:transform .2s ease;margin-left:2px;}
26059 .bug-report-trigger.open .br-chevron{transform:rotate(180deg);}
26060 .bug-report-panel{display:none;flex-direction:column;gap:12px;margin-top:18px;}
26061 .bug-report-panel.open{display:flex;}
26062 .br-network-badge{display:none;align-items:center;gap:6px;padding:4px 12px;border-radius:20px;font-size:11px;font-weight:700;width:fit-content;}
26063 .br-network-badge.online{background:#e8f5ee;color:#2a6846;}
26064 .br-network-badge.offline{background:#fff4e5;color:#9a5b00;}
26065 body.dark-theme .br-network-badge.online{background:#1a3d2b;color:#5aba8a;}
26066 body.dark-theme .br-network-badge.offline{background:#3d2a00;color:#f0a940;}
26067 .br-net-dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0;}
26068 .br-network-badge.online .br-net-dot{background:#2a6846;}
26069 .br-network-badge.offline .br-net-dot{background:#9a5b00;}
26070 body.dark-theme .br-network-badge.online .br-net-dot{background:#5aba8a;}
26071 body.dark-theme .br-network-badge.offline .br-net-dot{background:#f0a940;}
26072 .bug-report-pre{background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:14px 16px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;line-height:1.65;color:var(--text);white-space:pre-wrap;overflow-wrap:anywhere;max-height:240px;overflow-y:auto;}
26073 .bug-report-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
26074 .btn-sm{display:inline-flex;align-items:center;gap:6px;min-height:34px;padding:0 12px;border-radius:10px;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;text-decoration:none;transition:background .15s ease;}
26075 .btn-sm:hover{background:var(--line);}
26076 .btn-sm svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;}
26077 .bug-report-hint{font-size:11px;color:var(--muted);line-height:1.5;}
26078 .bug-report-hint a{color:var(--oxide);text-decoration:none;font-weight:700;}
26079 .bug-report-hint a:hover{text-decoration:underline;}
26080 .site-footer{margin-top:auto;padding:16px 24px;text-align:center;font-size:11px;color:var(--muted);border-top:1px solid var(--line);position:relative;z-index:1;}
26081 .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
26082 .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;}
26083 .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;}
26084 .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;}
26085 @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));}}
26086 .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;white-space:nowrap;text-decoration:none;}.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;}
26087 </style>
26088</head>
26089<body>
26090 <div class="background-watermarks" aria-hidden="true">
26091 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26092 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26093 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26094 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26095 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26096 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26097 </div>
26098 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26099 <div class="top-nav">
26100 <div class="top-nav-inner">
26101 <a class="brand" href="/">
26102 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
26103 <div class="brand-copy">
26104 <div class="brand-title">OxideSLOC</div>
26105 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26106 </div>
26107 </a>
26108 <div class="nav-right">
26109 <a class="nav-pill" href="/">Home</a>
26110 <div class="nav-dropdown">
26111 <a href="/view-reports" class="nav-dropdown-btn">View Reports <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></a>
26112 <div class="nav-dropdown-menu">
26113 <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
26114 </div>
26115 </div>
26116 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
26117 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26118 <div class="nav-dropdown">
26119 <a href="/git-browser" class="nav-dropdown-btn">Git Browser <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></a>
26120 <div class="nav-dropdown-menu">
26121 <a href="/integrations"><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>Integrations</a>
26122 </div>
26123 </div>
26124 <div class="server-status-wrap" id="server-status-wrap">
26125 <div class="nav-pill server-online-pill" id="server-status-pill">
26126 <span class="status-dot" id="status-dot"></span>
26127 <span id="server-status-label">Server</span>
26128 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26129 </div>
26130 <div class="server-status-tip">
26131 OxideSLOC is running — accessible on your network.
26132 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26133 </div>
26134 </div>
26135 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26136 <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
26137 </button>
26138 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26139 <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>
26140 <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>
26141 </button>
26142 </div>
26143 </div>
26144 </div>
26145
26146 <div class="page">
26147 <div class="panel">
26148 <h1>Error</h1>
26149 <div class="error-box" id="error-msg-text">{{ message }}</div>
26150 <div id="br-meta" hidden
26151 data-version="{{ version }}"
26152 data-run-id="{% if let Some(rid) = run_id %}{{ rid }}{% endif %}"
26153 data-error-code="{% if let Some(code) = error_code %}{{ code }}{% endif %}"></div>
26154 <div class="actions">
26155 <a class="btn-primary" href="/scan">Back to setup</a>
26156 {% if let Some(report_url) = last_report_url %}
26157 <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
26158 {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
26159 {% else %}
26160 <a class="btn-secondary" href="/view-reports">View Reports</a>
26161 {% endif %}
26162 </div>
26163 <div class="bug-report-section" id="bug-report-section">
26164 <button type="button" class="bug-report-trigger" id="bug-report-trigger" aria-expanded="false" aria-controls="bug-report-panel">
26165 <svg class="br-icon" viewBox="0 0 24 24"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
26166 Generate Bug Report
26167 <svg class="br-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
26168 </button>
26169 <div class="bug-report-panel" id="bug-report-panel" role="region" aria-label="Bug report">
26170 <div class="br-network-badge" id="br-network-badge"><span class="br-net-dot"></span><span id="br-network-label">Checking…</span></div>
26171 <pre class="bug-report-pre" id="bug-report-pre">Collecting info…</pre>
26172 <div class="bug-report-btns">
26173 <button type="button" class="btn-sm" id="bug-report-copy">
26174 <svg viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
26175 Copy to clipboard
26176 </button>
26177 <a class="btn-sm" id="bug-report-github-link" href="https://github.com/oxide-sloc/oxide-sloc/issues/new" target="_blank" rel="noopener noreferrer" style="display:none;">
26178 <svg viewBox="0 0 24 24"><path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844a9.59 9.59 0 0 1 2.504.337c1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0 0 22 12.017C22 6.484 17.522 2 12 2z"/></svg>
26179 Open GitHub Issue
26180 </a>
26181 <button type="button" class="btn-sm" id="bug-report-save" style="display:none;">
26182 <svg viewBox="0 0 24 24"><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>
26183 Save as file
26184 </button>
26185 </div>
26186 <p class="bug-report-hint" id="br-hint-online" style="display:none;">Paste the report into a new GitHub issue, or click <strong>Open GitHub Issue</strong> to open a pre-filled draft. Remove any file paths you prefer not to share before posting.</p>
26187 <p class="bug-report-hint" id="br-hint-offline" style="display:none;"><strong>Air-gapped system detected</strong> — GitHub is not reachable from this machine. Copy or save the report above, then open a <a href="https://github.com/oxide-sloc/oxide-sloc/issues/new" target="_blank" rel="noopener noreferrer">GitHub issue</a> from a connected machine and paste it there.</p>
26188 </div>
26189 </div>
26190 </div>
26191 </div>
26192 <footer class="site-footer">
26193 oxide-sloc v{{ version }} — local code metrics workbench ·
26194 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26195 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26196 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26197 · <a href="/api-docs" rel="noopener">REST API</a>
26198 </footer>
26199 <script nonce="{{ csp_nonce }}">(function(){
26200 var meta=document.getElementById('br-meta');
26201 var pre=document.getElementById('bug-report-pre');
26202 var copyBtn=document.getElementById('bug-report-copy');
26203 var trigger=document.getElementById('bug-report-trigger');
26204 var panel=document.getElementById('bug-report-panel');
26205 var networkBadge=document.getElementById('br-network-badge');
26206 var networkLabel=document.getElementById('br-network-label');
26207 var ghLink=document.getElementById('bug-report-github-link');
26208 var saveBtn=document.getElementById('bug-report-save');
26209 var hintOnline=document.getElementById('br-hint-online');
26210 var hintOffline=document.getElementById('br-hint-offline');
26211 if(!meta||!pre)return;
26212 var ver=meta.getAttribute('data-version')||'';
26213 var runId=meta.getAttribute('data-run-id')||'';
26214 var code=meta.getAttribute('data-error-code')||'';
26215 var msgEl=document.getElementById('error-msg-text');
26216 var msg=msgEl?msgEl.textContent.trim():'';
26217 function getBrowser(){
26218 var ua=navigator.userAgent;
26219 var m=ua.match(/(Edg|OPR|Chrome|Firefox|Safari)\/(\d+)/);
26220 if(!m)return 'Unknown browser';
26221 var n={'Edg':'Edge','OPR':'Opera'}[m[1]]||m[1];
26222 return n+' '+m[2];
26223 }
26224 var lines=['oxide-sloc Bug Report','==============================',''];
26225 lines.push('App version: v'+ver);
26226 if(code)lines.push('HTTP status: '+code);
26227 if(runId)lines.push('Run ID: '+runId);
26228 lines.push('Page: '+window.location.pathname+(window.location.search||''));
26229 lines.push('Timestamp: '+new Date().toISOString());
26230 lines.push('Browser: '+getBrowser());
26231 lines.push('Viewport: '+window.innerWidth+'x'+window.innerHeight);
26232 lines.push('');
26233 lines.push('Error message:');
26234 lines.push(msg);
26235 lines.push('');
26236 lines.push('Steps to reproduce:');
26237 lines.push(' 1. ');
26238 lines.push('');
26239 lines.push('Expected behavior:');
26240 lines.push(' ');
26241 pre.textContent=lines.join('\n');
26242 function applyNetwork(online){
26243 if(networkBadge){networkBadge.style.display='inline-flex';networkBadge.className='br-network-badge '+(online?'online':'offline');}
26244 if(networkLabel)networkLabel.textContent=online?'Internet connected':'Air-gapped / offline';
26245 if(ghLink){
26246 if(online){
26247 var body=encodeURIComponent(pre.textContent+'\n\n---\n*Generated by oxide-sloc v'+ver+'*');
26248 ghLink.href='https://github.com/oxide-sloc/oxide-sloc/issues/new?title=Bug+Report&body='+body;
26249 }
26250 ghLink.style.display=online?'inline-flex':'none';
26251 }
26252 if(saveBtn)saveBtn.style.display=online?'none':'inline-flex';
26253 if(hintOnline)hintOnline.style.display=online?'block':'none';
26254 if(hintOffline)hintOffline.style.display=online?'none':'block';
26255 }
26256 applyNetwork(navigator.onLine);
26257 var probed=false;
26258 function probeNetwork(){
26259 if(probed)return;probed=true;
26260 var probeUrls=['https://github.com','https://www.google.com','https://www.cloudflare.com'];
26261 var probeIdx=0;
26262 function tryNext(){
26263 if(probeIdx>=probeUrls.length){applyNetwork(false);return;}
26264 var u=probeUrls[probeIdx++];
26265 var c2=new AbortController();
26266 var t2=setTimeout(function(){c2.abort();},4000);
26267 fetch(u,{mode:'no-cors',cache:'no-store',signal:c2.signal})
26268 .then(function(){clearTimeout(t2);applyNetwork(true);})
26269 .catch(function(){clearTimeout(t2);tryNext();});
26270 }
26271 tryNext();
26272 }
26273 if(trigger&&panel){
26274 trigger.addEventListener('click',function(){
26275 var open=panel.classList.toggle('open');
26276 trigger.classList.toggle('open',open);
26277 trigger.setAttribute('aria-expanded',open?'true':'false');
26278 if(open)probeNetwork();
26279 });
26280 }
26281 if(copyBtn){
26282 copyBtn.addEventListener('click',function(){
26283 var txt=pre.textContent;
26284 if(navigator.clipboard&&navigator.clipboard.writeText){
26285 navigator.clipboard.writeText(txt).then(function(){
26286 copyBtn.textContent='\u2713 Copied!';
26287 setTimeout(function(){copyBtn.innerHTML='<svg viewBox="0 0 24 24" style="width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> Copy to clipboard';},2000);
26288 });
26289 }else{
26290 var ta=document.createElement('textarea');
26291 ta.value=txt;ta.style.position='fixed';ta.style.opacity='0';
26292 document.body.appendChild(ta);ta.select();
26293 try{document.execCommand('copy');copyBtn.textContent='\u2713 Copied!';}catch(e){}
26294 document.body.removeChild(ta);
26295 }
26296 });
26297 }
26298 if(saveBtn){
26299 saveBtn.addEventListener('click',function(){
26300 var txt=pre.textContent;
26301 var blob=new Blob([txt],{type:'text/plain'});
26302 var url=URL.createObjectURL(blob);
26303 var a=document.createElement('a');
26304 a.href=url;a.download='oxide-sloc-bug-report-'+new Date().toISOString().slice(0,10)+'.txt';
26305 document.body.appendChild(a);a.click();
26306 document.body.removeChild(a);URL.revokeObjectURL(url);
26307 });
26308 }
26309 })();</script>
26310 <script nonce="{{ csp_nonce }}">
26311 (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");});})();
26312 (function spawnCodeParticles() {
26313 var container = document.getElementById('code-particles');
26314 if (!container) return;
26315 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'];
26316 for (var i = 0; i < 38; i++) {
26317 (function(idx) {
26318 var el = document.createElement('span');
26319 el.className = 'code-particle';
26320 el.textContent = snippets[idx % snippets.length];
26321 var left = Math.random() * 94 + 2;
26322 var top = Math.random() * 88 + 6;
26323 var dur = (Math.random() * 10 + 9).toFixed(1);
26324 var delay = (Math.random() * 18).toFixed(1);
26325 var rot = (Math.random() * 26 - 13).toFixed(1);
26326 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
26327 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';
26328 container.appendChild(el);
26329 })(i);
26330 }
26331 })();
26332 (function randomizeWatermarks() {
26333 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
26334 var placed = [];
26335 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; }
26336 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]; }
26337 var half = Math.floor(wms.length/2);
26338 wms.forEach(function(img, i) {
26339 var pos = pick(i < half);
26340 var w = Math.floor(Math.random()*60+80);
26341 var rot = (Math.random()*40-20).toFixed(1);
26342 var op = (Math.random()*0.08+0.05).toFixed(2);
26343 var animDur = (Math.random()*6+5).toFixed(1);
26344 var animDelay = (Math.random()*10).toFixed(1);
26345 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';
26346 });
26347 })();
26348 </script>
26349 <script nonce="{{ csp_nonce }}">
26350 (function(){
26351 var S=[{n:'Classic',a:'#b85d33',b:'#7a371b'},{n:'Navy',a:'#283790',b:'#1e1e24'},{n:'Ember',a:'#ce5d3d',b:'#1e1e24'},{n:'Ocean',a:'#1f439b',b:'#1e1e24'},{n:'Royal',a:'#003184',b:'#1e1e24'}];
26352 function ap(s){document.documentElement.style.setProperty('--nav',s.a);document.documentElement.style.setProperty('--nav-2',s.b);try{localStorage.setItem('sloc-ns',JSON.stringify(s));}catch(e){}document.querySelectorAll('.scheme-swatch').forEach(function(x){x.classList.toggle('active',x.dataset.n===s.n);});}
26353 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26354 function init(){
26355 var btn=document.getElementById('settings-btn');if(!btn)return;
26356 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
26357 m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
26358 document.body.appendChild(m);
26359 var g=document.getElementById('scheme-grid');
26360 if(g)S.forEach(function(s){var el=document.createElement('button');el.type='button';el.className='scheme-swatch';el.dataset.n=s.n;el.title=s.n;var p=document.createElement('div');p.className='scheme-preview';p.style.background='linear-gradient(135deg,'+s.a+','+s.b+')';var l=document.createElement('span');l.className='scheme-label';l.textContent=s.n;el.appendChild(p);el.appendChild(l);try{var c=JSON.parse(localStorage.getItem('sloc-ns'));if(c&&c.n===s.n)el.classList.add('active');}catch(e){}el.addEventListener('click',function(){ap(s);});g.appendChild(el);});
26361 var cl=document.getElementById('settings-close');
26362 window.tzAbbr=function(z){return{'America/Los_Angeles':'PT','America/Denver':'MT','America/Chicago':'CT','America/New_York':'ET','America/Anchorage':'AT','Pacific/Honolulu':'HT'}[z]||'PT';};window.tzCity=function(z){return{'America/Los_Angeles':'Los Angeles','America/Denver':'Denver','America/Chicago':'Chicago','America/New_York':'New York','America/Anchorage':'Anchorage','Pacific/Honolulu':'Honolulu'}[z]||'';};window.tzOffset=function(z){var r='';try{var p=new Intl.DateTimeFormat('en-US',{timeZone:z,timeZoneName:'longOffset'}).formatToParts(new Date());p.forEach(function(x){if(x.type==='timeZoneName')r=x.value.replace('GMT','UTC');});}catch(e){}return r;};window.tf24=function(){try{return localStorage.getItem('sloc-tf')!=='12';}catch(e){return true;}};window.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';var h24=window.tf24();try{var pts=new Intl.DateTimeFormat('en-US',{timeZone:tz,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:!h24}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});var t=v.hour+':'+v.minute;if(!h24&&v.dayPeriod)t+=' '+v.dayPeriod;return v.year+'-'+v.month+'-'+v.day+' '+t+' '+window.tzAbbr(tz);}catch(e){return'';}};window.enhanceTzOptions=function(sel){if(!sel)return;Array.prototype.forEach.call(sel.options,function(o){var base=o.textContent.split(' - ')[0];var city=window.tzCity(o.value);var off=window.tzOffset(o.value);o.textContent=base+(city?' - '+city:'')+(off?' - '+off:'');});};window.applyTz=function(tz){try{localStorage.setItem('sloc-tz',tz);}catch(e){}document.querySelectorAll('[data-utc-ms]').forEach(function(el){var ms=parseInt(el.getAttribute('data-utc-ms'),10);if(!isNaN(ms))el.textContent=window.fmtTz(ms,tz);});};window.applyTf=function(tf){try{localStorage.setItem('sloc-tf',tf);}catch(e){}var z;try{z=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){z='America/Los_Angeles';}window.applyTz(z);};var tzSel=document.getElementById('tz-select');window.enhanceTzOptions(tzSel);var storedTz;try{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){storedTz='America/Los_Angeles';}if(tzSel){tzSel.value=storedTz;tzSel.addEventListener('change',function(){window.applyTz(this.value);});}window.applyTz(storedTz);(function(){var tzp=document.getElementById('tz-select');if(!tzp||document.getElementById('tf-select')||!tzp.parentNode)return;var tw=document.createElement('div');tw.style.marginTop='10px';var tl=document.createElement('div');tl.className='settings-modal-label';tl.style.marginBottom='8px';tl.textContent='Time format';var tfSel=document.createElement('select');tfSel.className='tz-select';tfSel.id='tf-select';tfSel.innerHTML='<option value="24">24-hour (14:30)</option><option value="12">12-hour (2:30 PM)</option>';tw.appendChild(tl);tw.appendChild(tfSel);tzp.parentNode.appendChild(tw);var storedTf;try{storedTf=localStorage.getItem('sloc-tf')||'24';}catch(e){storedTf='24';}tfSel.value=storedTf;tfSel.addEventListener('change',function(){window.applyTf(this.value);});})();
26363 btn.addEventListener('click',function(e){e.stopPropagation();var r=btn.getBoundingClientRect();m.style.top=(r.bottom+6)+'px';m.style.right=(window.innerWidth-r.right)+'px';m.classList.toggle('open');});
26364 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
26365 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
26366 }
26367 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26368 }());
26369 </script>
26370 <script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';
26371 if(location.protocol==='file:'){if(lbl)lbl.textContent='Offline';if(dot){dot.style.background='#888';dot.style.boxShadow='none';}if(pingEl)pingEl.textContent='';if(fm)fm.textContent='oxide-sloc v{{ version }} \u2014 Saved Report';var td=document.querySelector('.server-status-tip');if(td)td.textContent='Saved HTML report \u2014 server not connected.';return;}
26372 if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
26373</body>
26374</html>
26375"##,
26376 ext = "html"
26377)]
26378struct ErrorTemplate {
26379 message: String,
26380 last_report_url: Option<String>,
26382 last_report_label: Option<String>,
26384 run_id: Option<String>,
26386 error_code: Option<u16>,
26388 csp_nonce: String,
26389 version: &'static str,
26390}
26391
26392#[derive(Template)]
26395#[template(
26396 source = r##"
26397<!doctype html>
26398<html lang="en">
26399<head>
26400 <meta charset="utf-8">
26401 <meta name="viewport" content="width=device-width, initial-scale=1">
26402 <title>OxideSLOC | Locate Report</title>
26403 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26404 <style nonce="{{ csp_nonce }}">
26405 :root{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;--line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;--muted-2:#a08878;--nav:#283790;--nav-2:#013e6b;--accent:#6f9bff;--accent-2:#4a78ee;--oxide:#d37a4c;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}
26406 body.dark-theme{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;--line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;--muted-2:#9c877a;}
26407 *{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);}body{display:flex;flex-direction:column;}
26408 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26409 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26410 .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);}
26411 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26412 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}.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));}
26413 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26414 .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;white-space:nowrap;}
26415 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26416 @media(max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
26417 @media(max-width:1150px){.nav-right{gap:4px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 8px;font-size:11px;min-height:34px;}.brand-subtitle{display:none;}.server-online-pill{width:34px;padding:0;justify-content:center;font-size:0;gap:0;min-height:34px;}}
26418 .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;}
26419 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26420 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26421 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26422 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26423 .theme-toggle .icon-sun{display:none;}body.dark-theme .theme-toggle .icon-sun{display:block;}body.dark-theme .theme-toggle .icon-moon{display:none;}
26424 .settings-modal{position:fixed;z-index:9999;background:var(--surface-2);border:1px solid var(--line-strong);border-radius:14px;box-shadow:0 12px 36px rgba(0,0,0,0.22);min-width:260px;max-width:320px;opacity:0;pointer-events:none;transform:translateY(-8px) scale(0.97);transition:opacity 0.18s ease,transform 0.18s ease;overflow:hidden;}
26425 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26426 .settings-modal-header{display:flex;align-items:center;justify-content:space-between;padding:14px 16px 10px;border-bottom:1px solid var(--line);font-size:13px;font-weight:800;color:var(--text);}
26427 .settings-close{background:none;border:none;cursor:pointer;width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--muted);border-radius:6px;padding:0;}
26428 .settings-close:hover{color:var(--text);background:var(--surface-2);}
26429 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26430 .settings-modal-body{padding:14px 16px 16px;}
26431 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26432 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26433 .scheme-swatch{display:flex;flex-direction:column;align-items:center;gap:5px;background:none;border:1.5px solid var(--line);border-radius:10px;cursor:pointer;padding:7px 4px 6px;transition:border-color 0.15s ease,transform 0.12s ease;}
26434 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26435 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26436 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26437 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26438 .tz-select{width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2);color:var(--text);font-size:12px;font-weight:600;cursor:pointer;outline:none;box-sizing:border-box;}
26439 .tz-select:focus{border-color:var(--oxide);}
26440 .page{width:100%;max-width:1404px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
26441 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
26442 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
26443 .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 20px;line-height:1.55;}
26444 .field-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:6px;}
26445 .filename-chip{display:inline-flex;align-items:center;gap:8px;background:var(--surface-2);border:1px solid var(--line-strong);border-radius:8px;padding:9px 14px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;margin-bottom:22px;word-break:break-all;}
26446 .filename-chip svg{flex:0 0 auto;opacity:0.6;}
26447 .locate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
26448 .locate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
26449 .locate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
26450 .locate-row{display:flex;gap:8px;align-items:stretch;}
26451 .locate-input{flex:1;min-width:0;padding:10px 14px;border-radius:10px;border:1px solid var(--line-strong);background:var(--surface);color:var(--text);font-size:12.5px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;}
26452 .locate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
26453 body.dark-theme .locate-input{background:var(--surface-2);}
26454 .warning-banner{display:none;align-items:center;gap:8px;background:#fff4e5;border:1px solid #f5a623;border-radius:8px;padding:10px 14px;font-size:12px;color:#7a4f00;margin-top:8px;line-height:1.4;}
26455 .warning-banner.show{display:flex;}
26456 .warning-banner svg{flex:0 0 auto;}
26457 body.dark-theme .warning-banner{background:#3d2800;border-color:#a06820;color:#ffcf7a;}
26458 .error-inline{display:none;align-items:flex-start;gap:10px;background:#fde8e8;border:1px solid #e07070;border-radius:10px;padding:12px 16px;font-size:13px;color:#7a1e1e;margin-top:12px;line-height:1.55;}
26459 .error-inline.show{display:flex;}
26460 .error-inline svg{flex:0 0 auto;margin-top:2px;}
26461 body.dark-theme .error-inline{background:#4a1e1e;border-color:#b85555;color:#ffb3b3;}
26462 .err-kv{border-collapse:collapse;margin:6px 0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;}
26463 .err-kv-k{padding:2px 14px 2px 0;font-weight:700;white-space:nowrap;vertical-align:top;opacity:.85;}
26464 .err-kv-v{padding:2px 0;word-break:break-all;vertical-align:top;}
26465 .err-kv-p{margin:0 0 4px;}
26466 .success-inline{display:none;align-items:center;gap:10px;background:#e8faf0;border:1px solid #4caf80;border-radius:10px;padding:12px 16px;font-size:13px;color:#1a6b3c;margin-top:12px;}
26467 .success-inline.show{display:flex;}
26468 body.dark-theme .success-inline{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
26469 .folder-hint-shell{border:1px solid var(--line);border-radius:14px;overflow:hidden;background:var(--surface);margin-top:20px;}
26470 .folder-hint-hdr{padding:11px 16px;background:linear-gradient(180deg,var(--surface-2),rgba(255,255,255,0.35));border-bottom:1px solid var(--line);display:flex;align-items:center;gap:8px;font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.07em;}
26471 body.dark-theme .folder-hint-hdr{background:linear-gradient(180deg,var(--surface-2),rgba(0,0,0,0.12));}
26472 .folder-hint-body{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;}
26473 .fh-row{display:flex;align-items:center;gap:6px;padding:7px 14px;border-bottom:1px solid rgba(0,0,0,0.04);}
26474 .fh-row:nth-child(odd){background:rgba(255,255,255,0.25);}
26475 body.dark-theme .fh-row:nth-child(odd){background:rgba(255,255,255,0.02);}
26476 .fh-row:last-child{border-bottom:none;}
26477 .fh-i1{padding-left:36px;}.fh-i2{padding-left:58px;}
26478 .fh-dir{font-weight:800;color:var(--text);}
26479 .fh-hl{color:var(--oxide);font-weight:700;}
26480 .fh-muted{color:var(--muted);}
26481 .fh-badge{margin-left:auto;font-size:11px;font-weight:700;color:var(--oxide);background:rgba(184,93,51,0.10);border:1px solid rgba(184,93,51,0.25);border-radius:6px;padding:2px 8px;white-space:nowrap;}
26482 body.dark-theme .fh-badge{background:rgba(255,140,90,0.15);border-color:rgba(255,140,90,0.30);}
26483 .fh-tog{color:var(--muted-2);font-size:13px;flex:0 0 14px;}
26484 .fh-bul{color:var(--muted-2);font-size:8px;flex:0 0 14px;text-align:center;opacity:0.5;}
26485 .btn-row{margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
26486 .btn-primary{display:inline-flex;align-items:center;justify-content:center;min-height:42px;padding:0 22px;border-radius:14px;border: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);cursor:pointer;}
26487 .btn-primary:disabled{opacity:0.4;cursor:not-allowed;box-shadow:none;}
26488 .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;cursor:pointer;}
26489 .btn-secondary:hover{background:var(--line);}
26490 .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;}
26491 .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;}
26492 .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;}
26493 @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));}}
26494 .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;white-space:nowrap;text-decoration:none;}.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;}
26495 .site-footer{margin-top:auto;padding:16px 24px;text-align:center;font-size:11px;color:var(--muted);border-top:1px solid var(--line);position:relative;z-index:1;}
26496 .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
26497 </style>
26498</head>
26499<body>
26500 <div class="background-watermarks" aria-hidden="true">
26501 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26502 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26503 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26504 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26505 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26506 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26507 </div>
26508 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26509 <div class="top-nav">
26510 <div class="top-nav-inner">
26511 <a class="brand" href="/">
26512 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
26513 <div class="brand-copy">
26514 <div class="brand-title">OxideSLOC</div>
26515 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26516 </div>
26517 </a>
26518 <div class="nav-right">
26519 <a class="nav-pill" href="/">Home</a>
26520 <div class="nav-dropdown">
26521 <a href="/view-reports" class="nav-dropdown-btn">View Reports <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></a>
26522 <div class="nav-dropdown-menu">
26523 <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
26524 </div>
26525 </div>
26526 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
26527 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26528 <div class="nav-dropdown">
26529 <a href="/git-browser" class="nav-dropdown-btn">Git Browser <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></a>
26530 <div class="nav-dropdown-menu">
26531 <a href="/integrations"><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>Integrations</a>
26532 </div>
26533 </div>
26534 <div class="server-status-wrap" id="server-status-wrap">
26535 <div class="nav-pill server-online-pill" id="server-status-pill">
26536 <span class="status-dot" id="status-dot"></span>
26537 <span id="server-status-label">Server</span>
26538 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26539 </div>
26540 <div class="server-status-tip">
26541 OxideSLOC is running — accessible on your network.
26542 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26543 </div>
26544 </div>
26545 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26546 <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
26547 </button>
26548 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26549 <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>
26550 <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>
26551 </button>
26552 </div>
26553 </div>
26554 </div>
26555
26556 <div class="page">
26557 <div id="locate-meta" hidden data-expected="{{ expected_filename }}" data-run-id="{{ run_id }}" data-redirect="/runs/{{ artifact_type }}/{{ run_id }}"></div>
26558 <div class="panel">
26559 <h1>Report File Not Found</h1>
26560 <p class="panel-subtitle">The report file could not be found — the output folder may have been moved or renamed. Select the <strong>top-level scan output folder</strong> to restore it.</p>
26561 <div class="field-label">Missing file</div>
26562 <div class="filename-chip">
26563 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
26564 {{ expected_filename }}
26565 </div>
26566 <div class="locate-section">
26567 <h2>Locate Scan Output Folder</h2>
26568 <p>Select the <strong>top-level scan output folder</strong> (the one named like <code>project_20260601-…</code> that contains the <code>html/</code>, <code>json/</code>, and <code>pdf/</code> subfolders).</p>
26569 <p>OxideSLOC will find the correct files inside automatically.</p>
26570 <div class="locate-row">
26571 <input type="text" id="locate-file-input"
26572 placeholder="e.g. C:\Desktop\over-here\project_20260601-0029-…"
26573 class="locate-input" autocomplete="off" spellcheck="false">
26574 {% if !server_mode %}
26575 <button type="button" id="browse-locate-btn" class="btn-secondary">Browse…</button>
26576 {% endif %}
26577 </div>
26578 <div class="warning-banner" id="filename-warning">
26579 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
26580 <span>Tip: select the <strong>folder</strong>, not an individual file. If you must pick a file directly, its name must match <strong>{{ expected_filename }}</strong>.</span>
26581 </div>
26582 <div class="error-inline" id="locate-error">
26583 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex:0 0 auto;margin-top:2px;"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
26584 <span id="locate-error-text"></span>
26585 </div>
26586 <div class="success-inline" id="locate-success">
26587 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex:0 0 auto;"><polyline points="20 6 9 17 4 12"/></svg>
26588 <span>Scan restored — loading report…</span>
26589 </div>
26590 <div class="btn-row">
26591 <button type="button" id="locate-submit-btn" class="btn-primary" disabled>Restore Report</button>
26592 <a class="btn-secondary" href="/view-reports">View Reports</a>
26593 </div>
26594 <div class="folder-hint-shell">
26595 <div class="folder-hint-hdr">
26596 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="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"/></svg>
26597 Expected Folder Structure — Select the Top-Level Folder
26598 </div>
26599 <div class="folder-hint-body">
26600 <div class="fh-row">
26601 <span class="fh-tog">►</span>
26602 <span class="fh-dir">project_20260601-0029-…/</span>
26603 <span class="fh-badge">← select this</span>
26604 </div>
26605 <div class="fh-row fh-i1">
26606 <span class="fh-tog">►</span>
26607 <span class="fh-dir">html/</span>
26608 </div>
26609 <div class="fh-row fh-i2">
26610 <span class="fh-bul">•</span>
26611 <span class="fh-hl">{{ expected_filename }}</span>
26612 </div>
26613 <div class="fh-row fh-i1">
26614 <span class="fh-tog">►</span>
26615 <span class="fh-dir">json/</span>
26616 </div>
26617 <div class="fh-row fh-i2">
26618 <span class="fh-bul">•</span>
26619 <span class="fh-muted">result_*.json</span>
26620 </div>
26621 <div class="fh-row fh-i1">
26622 <span class="fh-tog">►</span>
26623 <span class="fh-dir">pdf/</span>
26624 </div>
26625 <div class="fh-row fh-i2">
26626 <span class="fh-bul">•</span>
26627 <span class="fh-muted">report_*.pdf</span>
26628 </div>
26629 <div class="fh-row fh-i1">
26630 <span class="fh-tog">►</span>
26631 <span class="fh-dir">excel/</span>
26632 </div>
26633 <div class="fh-row fh-i2">
26634 <span class="fh-bul">•</span>
26635 <span class="fh-muted">report_*.csv report_*.xlsx</span>
26636 </div>
26637 </div>
26638 </div>
26639 </div>
26640 </div>
26641 </div>
26642 <footer class="site-footer">
26643 oxide-sloc v{{ version }} — local code metrics workbench ·
26644 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26645 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26646 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26647 · <a href="/api-docs" rel="noopener">REST API</a>
26648 </footer>
26649 <script nonce="{{ csp_nonce }}">(function(){
26650 var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
26651 if(s==="dark")b.classList.add("dark-theme");
26652 document.getElementById("theme-toggle").addEventListener("click",function(){
26653 var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");
26654 });
26655 })();</script>
26656 <script nonce="{{ csp_nonce }}">(function spawnCodeParticles(){
26657 var c=document.getElementById('code-particles');if(!c)return;
26658 var snips=['report moved','fn analyze()','locate file','.html report','restore path','folder path','result.json','run_id','pub fn run','use std::fs','Result<()>','git main','files: 60','cargo build','Ok(run)','match lang','fn main() {','.rs .go .py','sloc_core','render_html'];
26659 for(var i=0;i<38;i++){(function(idx){var el=document.createElement('span');el.className='code-particle';el.textContent=snips[idx%snips.length];var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1),dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1),rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';c.appendChild(el);})(i);}
26660 })();
26661 (function randomizeWatermarks(){var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));if(!wms.length)return;var placed=[];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;}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];}var half=Math.floor(wms.length/2);wms.forEach(function(img,i){var pos=pick(i<half),w=Math.floor(Math.random()*100+120),rot=(Math.random()*360).toFixed(1),op=(Math.random()*0.08+0.12).toFixed(2);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;});})();</script>
26662 <script nonce="{{ csp_nonce }}">(function(){
26663 var S=[{n:'Classic',a:'#b85d33',b:'#7a371b'},{n:'Navy',a:'#283790',b:'#1e1e24'},{n:'Ember',a:'#ce5d3d',b:'#1e1e24'},{n:'Ocean',a:'#1f439b',b:'#1e1e24'},{n:'Royal',a:'#003184',b:'#1e1e24'}];
26664 function ap(s){document.documentElement.style.setProperty('--nav',s.a);document.documentElement.style.setProperty('--nav-2',s.b);try{localStorage.setItem('sloc-ns',JSON.stringify(s));}catch(e){}document.querySelectorAll('.scheme-swatch').forEach(function(x){x.classList.toggle('active',x.dataset.n===s.n);});}
26665 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26666 function init(){var btn=document.getElementById('settings-btn');if(!btn)return;var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';document.body.appendChild(m);var g=document.getElementById('scheme-grid');if(g)S.forEach(function(s){var el=document.createElement('button');el.type='button';el.className='scheme-swatch';el.dataset.n=s.n;el.title=s.n;var p=document.createElement('div');p.className='scheme-preview';p.style.background='linear-gradient(135deg,'+s.a+','+s.b+')';var l=document.createElement('span');l.className='scheme-label';l.textContent=s.n;el.appendChild(p);el.appendChild(l);try{var c=JSON.parse(localStorage.getItem('sloc-ns'));if(c&&c.n===s.n)el.classList.add('active');}catch(e){}el.addEventListener('click',function(){ap(s);});g.appendChild(el);});var cl=document.getElementById('settings-close');window.tzAbbr=function(z){return{'America/Los_Angeles':'PT','America/Denver':'MT','America/Chicago':'CT','America/New_York':'ET','America/Anchorage':'AT','Pacific/Honolulu':'HT'}[z]||'PT';};window.tzCity=function(z){return{'America/Los_Angeles':'Los Angeles','America/Denver':'Denver','America/Chicago':'Chicago','America/New_York':'New York','America/Anchorage':'Anchorage','Pacific/Honolulu':'Honolulu'}[z]||'';};window.tzOffset=function(z){var r='';try{var p=new Intl.DateTimeFormat('en-US',{timeZone:z,timeZoneName:'longOffset'}).formatToParts(new Date());p.forEach(function(x){if(x.type==='timeZoneName')r=x.value.replace('GMT','UTC');});}catch(e){}return r;};window.tf24=function(){try{return localStorage.getItem('sloc-tf')!=='12';}catch(e){return true;}};window.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';var h24=window.tf24();try{var pts=new Intl.DateTimeFormat('en-US',{timeZone:tz,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:!h24}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});var t=v.hour+':'+v.minute;if(!h24&&v.dayPeriod)t+=' '+v.dayPeriod;return v.year+'-'+v.month+'-'+v.day+' '+t+' '+window.tzAbbr(tz);}catch(e){return'';}};window.enhanceTzOptions=function(sel){if(!sel)return;Array.prototype.forEach.call(sel.options,function(o){var base=o.textContent.split(' - ')[0];var city=window.tzCity(o.value);var off=window.tzOffset(o.value);o.textContent=base+(city?' - '+city:'')+(off?' - '+off:'');});};window.applyTz=function(tz){try{localStorage.setItem('sloc-tz',tz);}catch(e){}document.querySelectorAll('[data-utc-ms]').forEach(function(el){var ms=parseInt(el.getAttribute('data-utc-ms'),10);if(!isNaN(ms))el.textContent=window.fmtTz(ms,tz);});};window.applyTf=function(tf){try{localStorage.setItem('sloc-tf',tf);}catch(e){}var z;try{z=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){z='America/Los_Angeles';}window.applyTz(z);};var tzSel=document.getElementById('tz-select');window.enhanceTzOptions(tzSel);var storedTz;try{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){storedTz='America/Los_Angeles';}if(tzSel){tzSel.value=storedTz;tzSel.addEventListener('change',function(){window.applyTz(this.value);});}window.applyTz(storedTz);(function(){var tzp=document.getElementById('tz-select');if(!tzp||document.getElementById('tf-select')||!tzp.parentNode)return;var tw=document.createElement('div');tw.style.marginTop='10px';var tl=document.createElement('div');tl.className='settings-modal-label';tl.style.marginBottom='8px';tl.textContent='Time format';var tfSel=document.createElement('select');tfSel.className='tz-select';tfSel.id='tf-select';tfSel.innerHTML='<option value="24">24-hour (14:30)</option><option value="12">12-hour (2:30 PM)</option>';tw.appendChild(tl);tw.appendChild(tfSel);tzp.parentNode.appendChild(tw);var storedTf;try{storedTf=localStorage.getItem('sloc-tf')||'24';}catch(e){storedTf='24';}tfSel.value=storedTf;tfSel.addEventListener('change',function(){window.applyTf(this.value);});})();btn.addEventListener('click',function(e){e.stopPropagation();var r=btn.getBoundingClientRect();m.style.top=(r.bottom+6)+'px';m.style.right=(window.innerWidth-r.right)+'px';m.classList.toggle('open');});if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});}
26667 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26668 }());</script>
26669 <script nonce="{{ csp_nonce }}">(function(){
26670 var meta=document.getElementById('locate-meta');
26671 var inp=document.getElementById('locate-file-input');
26672 var browseBtn=document.getElementById('browse-locate-btn');
26673 var submitBtn=document.getElementById('locate-submit-btn');
26674 var warning=document.getElementById('filename-warning');
26675 var errBox=document.getElementById('locate-error');
26676 var errText=document.getElementById('locate-error-text');
26677 var okBox=document.getElementById('locate-success');
26678 var expected=meta?meta.getAttribute('data-expected'):'';
26679 var runId=meta?meta.getAttribute('data-run-id'):'';
26680 var redirectUrl=meta?meta.getAttribute('data-redirect'):'/view-reports';
26681 function basename(p){return p.replace(/\\/g,'/').split('/').pop()||'';}
26682 function showErr(msg){
26683 if(errText){
26684 errText.innerHTML='';
26685 var lines=msg.split('\n');
26686 var hasPairs=lines.some(function(l){return / : /.test(l);});
26687 if(!hasPairs){errText.textContent=msg;}
26688 else{
26689 var frag=document.createDocumentFragment();var tbl=null;
26690 lines.forEach(function(line){
26691 var m=line.match(/^(.*?) : (.*)$/);
26692 if(m){
26693 if(!tbl){tbl=document.createElement('table');tbl.className='err-kv';frag.appendChild(tbl);}
26694 var tr=document.createElement('tr');
26695 var k=document.createElement('td');k.className='err-kv-k';k.textContent=m[1].trim();
26696 var v=document.createElement('td');v.className='err-kv-v';v.textContent=m[2];
26697 tr.appendChild(k);tr.appendChild(v);tbl.appendChild(tr);
26698 } else {
26699 tbl=null;
26700 if(line.trim()){var p=document.createElement('p');p.className='err-kv-p';p.textContent=line.trim();frag.appendChild(p);}
26701 }
26702 });
26703 errText.appendChild(frag);
26704 }
26705 }
26706 if(errBox)errBox.classList.add('show');
26707 if(okBox)okBox.classList.remove('show');
26708 }
26709 function clearErr(){
26710 if(errBox)errBox.classList.remove('show');
26711 if(okBox)okBox.classList.remove('show');
26712 }
26713 function validate(){
26714 var val=inp?inp.value.trim():'';
26715 clearErr();
26716 if(!val){if(submitBtn)submitBtn.disabled=true;if(warning)warning.classList.remove('show');return;}
26717 if(submitBtn)submitBtn.disabled=false;
26718 if(warning){
26719 var name=basename(val);
26720 var looksLikeFile=name.toLowerCase().slice(-5)==='.html';
26721 if(expected&&name&&looksLikeFile&&name!==expected)warning.classList.add('show');
26722 else warning.classList.remove('show');
26723 }
26724 }
26725 if(inp){inp.addEventListener('input',validate);inp.addEventListener('keydown',function(e){if(e.key==='Enter')submitBtn&&submitBtn.click();});}
26726 if(browseBtn){
26727 browseBtn.addEventListener('click',function(){
26728 browseBtn.disabled=true;browseBtn.textContent='...';
26729 fetch('/pick-directory')
26730 .then(function(r){return r.ok?r.json():{cancelled:true};})
26731 .then(function(d){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';if(d&&d.selected_path&&inp){inp.value=d.selected_path;validate();}})
26732 .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
26733 });
26734 }
26735 if(submitBtn){
26736 submitBtn.addEventListener('click',function(){
26737 var folder=inp?inp.value.trim():'';
26738 if(!folder){showErr('Please enter or browse to the scan output folder.');return;}
26739 clearErr();
26740 submitBtn.disabled=true;submitBtn.textContent='Restoring\u2026';
26741 var body=new URLSearchParams();
26742 body.set('file_path',folder);
26743 body.set('redirect_url',redirectUrl);
26744 body.set('expected_run_id',runId);
26745 fetch('/locate-report',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
26746 .then(function(r){return r.json().catch(function(){return{ok:false,message:'Server returned an unexpected response (status '+r.status+').'}; });})
26747 .then(function(d){
26748 submitBtn.disabled=false;submitBtn.textContent='Restore Report';
26749 if(d&&d.ok){
26750 if(okBox)okBox.classList.add('show');
26751 setTimeout(function(){window.location.href=d.redirect||redirectUrl;},500);
26752 } else {
26753 showErr(d&&d.message?d.message:'Unknown error. Check that the folder contains the correct scan.');
26754 }
26755 })
26756 .catch(function(e){
26757 submitBtn.disabled=false;submitBtn.textContent='Restore Report';
26758 showErr('Network error: '+String(e));
26759 });
26760 });
26761 }
26762 })();</script>
26763 <script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl)lbl.textContent=isServer?'Server':'Local';function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
26764</body>
26765</html>
26766"##,
26767 ext = "html"
26768)]
26769struct LocateFileTemplate {
26770 run_id: String,
26771 artifact_type: String,
26772 expected_filename: String,
26773 server_mode: bool,
26774 csp_nonce: String,
26775 version: &'static str,
26776}
26777
26778#[derive(Template)]
26781#[template(
26782 source = r##"
26783<!doctype html>
26784<html lang="en">
26785<head>
26786 <meta charset="utf-8">
26787 <meta name="viewport" content="width=device-width, initial-scale=1">
26788 <title>OxideSLOC | Locate Scan Files</title>
26789 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26790 <style nonce="{{ csp_nonce }}">
26791 :root {
26792 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
26793 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
26794 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
26795 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
26796 }
26797 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
26798 *{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);} body{display:flex;flex-direction:column;}
26799 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26800 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26801 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
26802 .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);}
26803 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26804 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;} .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));}
26805 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26806 .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;white-space:nowrap;}
26807 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26808 @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
26809 @media (max-width:1150px){.nav-right{gap:4px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 8px;font-size:11px;min-height:34px;}.brand-subtitle{display:none;}.server-online-pill{width:34px;padding:0;justify-content:center;font-size:0;gap:0;min-height:34px;}}
26810 .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;}
26811 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26812 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26813 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26814 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26815 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
26816 .settings-modal{position:fixed;z-index:9999;background:var(--surface-2);border:1px solid var(--line-strong);border-radius:14px;box-shadow:0 12px 36px rgba(0,0,0,0.22);min-width:260px;max-width:320px;opacity:0;pointer-events:none;transform:translateY(-8px) scale(0.97);transition:opacity 0.18s ease,transform 0.18s ease;overflow:hidden;}
26817 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26818 .settings-modal-header{display:flex;align-items:center;justify-content:space-between;padding:14px 16px 10px;border-bottom:1px solid var(--line);font-size:13px;font-weight:800;color:var(--text);}
26819 .settings-close{background:none;border:none;cursor:pointer;width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--muted);border-radius:6px;padding:0;}
26820 .settings-close:hover{color:var(--text);background:var(--surface-2);}
26821 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26822 .settings-modal-body{padding:14px 16px 16px;}
26823 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26824 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26825 .scheme-swatch{display:flex;flex-direction:column;align-items:center;gap:5px;background:none;border:1.5px solid var(--line);border-radius:10px;cursor:pointer;padding:7px 4px 6px;transition:border-color 0.15s ease,transform 0.12s ease;}
26826 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26827 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26828 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26829 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26830 .tz-select{width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2);color:var(--text);font-size:12px;font-weight:600;cursor:pointer;outline:none;box-sizing:border-box;}
26831 .tz-select:focus{border-color:var(--oxide);}
26832 .page{max-width:1560px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
26833 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
26834 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
26835 .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
26836 .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:12.5px;margin-bottom:22px;}
26837 .error-box.hidden{display:none;}
26838 .success-box{border-radius:16px;border:1px solid #a3d9b5;background:#eafaf0;padding:16px 18px;font-size:13px;font-weight:600;color:#1a6b3c;margin-bottom:22px;display:none;}
26839 body.dark-theme .success-box{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
26840 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
26841 .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);cursor:pointer;}
26842 .site-footer{margin-top:auto;padding:18px 24px;text-align:center;font-size:12px;color:var(--muted);border-top:1px solid var(--line);background:transparent;}
26843 .site-footer a{color:var(--oxide);text-decoration:none;}.site-footer a:hover{text-decoration:underline;}
26844 .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;cursor:pointer;}
26845 .btn-secondary:hover{background:var(--line);}
26846 .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;}
26847 .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;}
26848 .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;}
26849 @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));}}
26850 .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;white-space:nowrap;text-decoration:none;}.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;}
26851 .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
26852 .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
26853 .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
26854 .relocate-row{display:flex;gap:8px;align-items:stretch;}
26855 .relocate-input{flex:1;min-width:0;padding:10px 14px;border-radius:10px;border:1px solid var(--line-strong);background:var(--surface);color:var(--text);font-size:12.5px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;}
26856 .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
26857 body.dark-theme .relocate-input{background:var(--surface-2);}
26858 </style>
26859</head>
26860<body>
26861 <div class="background-watermarks" aria-hidden="true">
26862 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26863 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26864 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26865 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26866 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26867 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26868 </div>
26869 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26870 <div class="top-nav">
26871 <div class="top-nav-inner">
26872 <a class="brand" href="/">
26873 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
26874 <div class="brand-copy">
26875 <div class="brand-title">OxideSLOC</div>
26876 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26877 </div>
26878 </a>
26879 <div class="nav-right">
26880 <a class="nav-pill" href="/">Home</a>
26881 <div class="nav-dropdown">
26882 <a href="/view-reports" class="nav-dropdown-btn">View Reports <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></a>
26883 <div class="nav-dropdown-menu">
26884 <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
26885 </div>
26886 </div>
26887 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
26888 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26889 <div class="nav-dropdown">
26890 <a href="/git-browser" class="nav-dropdown-btn">Git Browser <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></a>
26891 <div class="nav-dropdown-menu">
26892 <a href="/integrations"><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>Integrations</a>
26893 </div>
26894 </div>
26895 <div class="server-status-wrap" id="server-status-wrap">
26896 <div class="nav-pill server-online-pill" id="server-status-pill">
26897 <span class="status-dot" id="status-dot"></span>
26898 <span id="server-status-label">Server</span>
26899 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26900 </div>
26901 <div class="server-status-tip">
26902 OxideSLOC is running — accessible on your network.
26903 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26904 </div>
26905 </div>
26906 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26907 <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
26908 </button>
26909 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26910 <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>
26911 <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>
26912 </button>
26913 </div>
26914 </div>
26915 </div>
26916
26917 <div class="page">
26918 <div class="panel">
26919 <h1>Scan Files Moved</h1>
26920 <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
26921 <div class="error-box" id="relocate-error-box">{{ message }}</div>
26922 <div class="success-box" id="relocate-success-box">Scan restored — redirecting…</div>
26923 <div class="relocate-section">
26924 <h2>Locate Scan Output</h2>
26925 <p>Select the <strong>top-level</strong> scan output folder (the one named <code>project_YYYYMMDD-HHMM-…</code>). Result files will be found inside it automatically — do not navigate into a subfolder.</p>
26926 <div class="relocate-row">
26927 <input type="text" id="relocate-folder" name="folder_path"
26928 value="{{ folder_hint }}"
26929 placeholder="Path to folder containing scan output..."
26930 class="relocate-input" autocomplete="off" spellcheck="false">
26931 {% if !server_mode %}
26932 <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse…</button>
26933 {% endif %}
26934 </div>
26935 <div style="margin-top:12px;">
26936 <button type="button" id="restore-btn" class="btn-primary" style="border:none;">Restore Scan</button>
26937 </div>
26938 </div>
26939 <div class="actions">
26940 <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
26941 <a class="btn-secondary" href="/view-reports">View Reports</a>
26942 </div>
26943 </div>
26944 </div>
26945 <footer class="site-footer">
26946 oxide-sloc v{{ version }} — local code metrics workbench ·
26947 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26948 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26949 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26950 · <a href="/api-docs" rel="noopener">REST API</a>
26951 </footer>
26952 <script nonce="{{ csp_nonce }}">
26953 (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");});})();
26954 (function spawnCodeParticles(){var c=document.getElementById('code-particles');if(!c)return;var snips=['scan moved','fn analyze()','result.json','.html .pdf','locate files','restore scan','folder path','result*.json','run_id','compare','pub fn run','use std::fs','Result<()>','git main','files: 60','cargo build','Ok(run)','match lang','fn main() {','.rs .go .py','sloc_core','render_html'];for(var i=0;i<38;i++){(function(idx){var el=document.createElement('span');el.className='code-particle';el.textContent=snips[idx%snips.length];var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1),dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1),rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';c.appendChild(el);})(i);}})();
26955 (function randomizeWatermarks(){var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));if(!wms.length)return;var placed=[];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;}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];}var half=Math.floor(wms.length/2);wms.forEach(function(img,i){var pos=pick(i<half),w=Math.floor(Math.random()*100+120),rot=(Math.random()*360).toFixed(1),op=(Math.random()*0.08+0.12).toFixed(2);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;});})();
26956 </script>
26957 <script nonce="{{ csp_nonce }}">
26958 (function(){
26959 var S=[{n:'Classic',a:'#b85d33',b:'#7a371b'},{n:'Navy',a:'#283790',b:'#1e1e24'},{n:'Ember',a:'#ce5d3d',b:'#1e1e24'},{n:'Ocean',a:'#1f439b',b:'#1e1e24'},{n:'Royal',a:'#003184',b:'#1e1e24'}];
26960 function ap(s){document.documentElement.style.setProperty('--nav',s.a);document.documentElement.style.setProperty('--nav-2',s.b);try{localStorage.setItem('sloc-ns',JSON.stringify(s));}catch(e){}document.querySelectorAll('.scheme-swatch').forEach(function(x){x.classList.toggle('active',x.dataset.n===s.n);});}
26961 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26962 function init(){
26963 var btn=document.getElementById('settings-btn');if(!btn)return;
26964 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
26965 m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
26966 document.body.appendChild(m);
26967 var g=document.getElementById('scheme-grid');
26968 if(g)S.forEach(function(s){var el=document.createElement('button');el.type='button';el.className='scheme-swatch';el.dataset.n=s.n;el.title=s.n;var p=document.createElement('div');p.className='scheme-preview';p.style.background='linear-gradient(135deg,'+s.a+','+s.b+')';var l=document.createElement('span');l.className='scheme-label';l.textContent=s.n;el.appendChild(p);el.appendChild(l);try{var c=JSON.parse(localStorage.getItem('sloc-ns'));if(c&&c.n===s.n)el.classList.add('active');}catch(e){}el.addEventListener('click',function(){ap(s);});g.appendChild(el);});
26969 var cl=document.getElementById('settings-close');
26970 window.tzAbbr=function(z){return{'America/Los_Angeles':'PT','America/Denver':'MT','America/Chicago':'CT','America/New_York':'ET','America/Anchorage':'AT','Pacific/Honolulu':'HT'}[z]||'PT';};window.tzCity=function(z){return{'America/Los_Angeles':'Los Angeles','America/Denver':'Denver','America/Chicago':'Chicago','America/New_York':'New York','America/Anchorage':'Anchorage','Pacific/Honolulu':'Honolulu'}[z]||'';};window.tzOffset=function(z){var r='';try{var p=new Intl.DateTimeFormat('en-US',{timeZone:z,timeZoneName:'longOffset'}).formatToParts(new Date());p.forEach(function(x){if(x.type==='timeZoneName')r=x.value.replace('GMT','UTC');});}catch(e){}return r;};window.tf24=function(){try{return localStorage.getItem('sloc-tf')!=='12';}catch(e){return true;}};window.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';var h24=window.tf24();try{var pts=new Intl.DateTimeFormat('en-US',{timeZone:tz,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:!h24}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});var t=v.hour+':'+v.minute;if(!h24&&v.dayPeriod)t+=' '+v.dayPeriod;return v.year+'-'+v.month+'-'+v.day+' '+t+' '+window.tzAbbr(tz);}catch(e){return'';}};window.enhanceTzOptions=function(sel){if(!sel)return;Array.prototype.forEach.call(sel.options,function(o){var base=o.textContent.split(' - ')[0];var city=window.tzCity(o.value);var off=window.tzOffset(o.value);o.textContent=base+(city?' - '+city:'')+(off?' - '+off:'');});};window.applyTz=function(tz){try{localStorage.setItem('sloc-tz',tz);}catch(e){}document.querySelectorAll('[data-utc-ms]').forEach(function(el){var ms=parseInt(el.getAttribute('data-utc-ms'),10);if(!isNaN(ms))el.textContent=window.fmtTz(ms,tz);});};window.applyTf=function(tf){try{localStorage.setItem('sloc-tf',tf);}catch(e){}var z;try{z=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){z='America/Los_Angeles';}window.applyTz(z);};var tzSel=document.getElementById('tz-select');window.enhanceTzOptions(tzSel);var storedTz;try{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){storedTz='America/Los_Angeles';}if(tzSel){tzSel.value=storedTz;tzSel.addEventListener('change',function(){window.applyTz(this.value);});}window.applyTz(storedTz);(function(){var tzp=document.getElementById('tz-select');if(!tzp||document.getElementById('tf-select')||!tzp.parentNode)return;var tw=document.createElement('div');tw.style.marginTop='10px';var tl=document.createElement('div');tl.className='settings-modal-label';tl.style.marginBottom='8px';tl.textContent='Time format';var tfSel=document.createElement('select');tfSel.className='tz-select';tfSel.id='tf-select';tfSel.innerHTML='<option value="24">24-hour (14:30)</option><option value="12">12-hour (2:30 PM)</option>';tw.appendChild(tl);tw.appendChild(tfSel);tzp.parentNode.appendChild(tw);var storedTf;try{storedTf=localStorage.getItem('sloc-tf')||'24';}catch(e){storedTf='24';}tfSel.value=storedTf;tfSel.addEventListener('change',function(){window.applyTf(this.value);});})();
26971 btn.addEventListener('click',function(e){e.stopPropagation();var r=btn.getBoundingClientRect();m.style.top=(r.bottom+6)+'px';m.style.right=(window.innerWidth-r.right)+'px';m.classList.toggle('open');});
26972 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
26973 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
26974 }
26975 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26976 }());
26977 (function(){
26978 var browseBtn=document.getElementById('browse-relocate-btn');
26979 if(browseBtn){
26980 browseBtn.addEventListener('click',function(){
26981 browseBtn.disabled=true;browseBtn.textContent='...';
26982 var inp=document.getElementById('relocate-folder');
26983 var hint=inp?inp.value:'';
26984 fetch('/pick-directory?kind=reports¤t='+encodeURIComponent(hint))
26985 .then(function(r){return r.ok?r.json():{cancelled:true};})
26986 .then(function(d){
26987 browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';
26988 if(d&&d.selected_path&&inp)inp.value=d.selected_path;
26989 })
26990 .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
26991 });
26992 }
26993 var restoreBtn=document.getElementById('restore-btn');
26994 var errBox=document.getElementById('relocate-error-box');
26995 var okBox=document.getElementById('relocate-success-box');
26996 if(restoreBtn){
26997 restoreBtn.addEventListener('click',function(){
26998 var inp=document.getElementById('relocate-folder');
26999 var folder=inp?inp.value.trim():'';
27000 if(!folder){if(errBox){errBox.textContent='Please enter a folder path.';errBox.classList.remove('hidden');}return;}
27001 restoreBtn.disabled=true;restoreBtn.textContent='Checking\u2026';
27002 var body=new URLSearchParams();
27003 body.set('run_id','{{ run_id }}');
27004 body.set('redirect_url','{{ redirect_url }}');
27005 body.set('folder_path',folder);
27006 fetch('/relocate-scan',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
27007 .then(function(r){return r.json();})
27008 .then(function(d){
27009 restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
27010 if(d&&d.ok){
27011 if(errBox)errBox.classList.add('hidden');
27012 if(okBox){okBox.style.display='block';}
27013 setTimeout(function(){window.location.href=d.redirect||'/compare-scans';},600);
27014 } else {
27015 if(errBox){errBox.textContent=d&&d.message?d.message:'Unknown error.';errBox.classList.remove('hidden');}
27016 }
27017 })
27018 .catch(function(e){
27019 restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
27020 if(errBox){errBox.textContent='Network error: '+String(e);errBox.classList.remove('hidden');}
27021 });
27022 });
27023 }
27024 }());
27025 </script>
27026 <script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';
27027 if(location.protocol==='file:'){if(lbl)lbl.textContent='Offline';if(dot){dot.style.background='#888';dot.style.boxShadow='none';}if(pingEl)pingEl.textContent='';if(fm)fm.textContent='oxide-sloc v{{ version }} \u2014 Saved Report';var td=document.querySelector('.server-status-tip');if(td)td.textContent='Saved HTML report \u2014 server not connected.';return;}
27028 if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
27029</body>
27030</html>
27031"##,
27032 ext = "html"
27033)]
27034struct RelocateScanTemplate {
27035 message: String,
27036 run_id: String,
27037 folder_hint: String,
27038 redirect_url: String,
27039 server_mode: bool,
27040 csp_nonce: String,
27041 version: &'static str,
27042}
27043
27044#[derive(Template)]
27047#[template(
27048 source = r##"
27049<!doctype html>
27050<html lang="en">
27051<head>
27052 <meta charset="utf-8">
27053 <meta name="viewport" content="width=device-width, initial-scale=1">
27054 <title>OxideSLOC | View Reports</title>
27055 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
27056 <style nonce="{{ csp_nonce }}">
27057 :root {
27058 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
27059 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
27060 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
27061 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
27062 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
27063 }
27064 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; }
27065 *{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);} body{display:flex;flex-direction:column;}
27066 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27067 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
27068 .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);}
27069 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
27070 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;} .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));}
27071 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
27072 .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;white-space:nowrap;}
27073 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
27074 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
27075 @media (max-width: 1150px) { .nav-right { gap: 4px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 8px; font-size: 11px; min-height: 34px; } .brand-subtitle { display: none; } .server-online-pill { width: 34px; padding: 0; justify-content: center; font-size: 0; gap: 0; min-height: 34px; } }
27076 .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;}
27077 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
27078 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
27079 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
27080 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
27081 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
27082 .settings-modal{position:fixed;z-index:9999;background:var(--surface-2);border:1px solid var(--line-strong);border-radius:14px;box-shadow:0 12px 36px rgba(0,0,0,0.22);min-width:260px;max-width:320px;opacity:0;pointer-events:none;transform:translateY(-8px) scale(0.97);transition:opacity 0.18s ease,transform 0.18s ease;overflow:hidden;}
27083 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
27084 .settings-modal-header{display:flex;align-items:center;justify-content:space-between;padding:14px 16px 10px;border-bottom:1px solid var(--line);font-size:13px;font-weight:800;color:var(--text);}
27085 .settings-close{background:none;border:none;cursor:pointer;width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--muted);border-radius:6px;padding:0;}
27086 .settings-close:hover{color:var(--text);background:var(--surface-2);}
27087 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
27088 .settings-modal-body{padding:14px 16px 16px;}
27089 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
27090 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
27091 .scheme-swatch{display:flex;flex-direction:column;align-items:center;gap:5px;background:none;border:1.5px solid var(--line);border-radius:10px;cursor:pointer;padding:7px 4px 6px;transition:border-color 0.15s ease,transform 0.12s ease;}
27092 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
27093 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
27094 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
27095 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
27096 .tz-select{width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2);color:var(--text);font-size:12px;font-weight:600;cursor:pointer;outline:none;box-sizing:border-box;}
27097 .tz-select:focus{border-color:var(--oxide);}
27098 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
27099 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
27100 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
27101 .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
27102 .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
27103 .panel-meta{font-size:13px;color:var(--muted);}
27104 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
27105 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
27106 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
27107 .per-page-label{font-size:13px;color:var(--muted);}
27108 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;}
27109 .filter-input{min-width:180px;cursor:text;}
27110 .table-wrap{width:100%;overflow-x:auto;}
27111 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
27112 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;}
27113 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
27114 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
27115 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
27116 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
27117 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
27118 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
27119 tr:last-child td{border-bottom:none;}
27120 tr:hover td{background:var(--surface-2);}
27121 .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);}
27122 .git-chip{font-family:ui-monospace,monospace;font-size:11px;font-weight:700;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);}
27123 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
27124 .metric-num{font-weight:700;color:var(--text);}
27125 .metric-secondary{font-size:11px;color:var(--muted);margin-top:3px;}
27126 .skipped-pill{font-size:10px;font-weight:600;font-style:italic;color:var(--muted);opacity:.9;font-variant-numeric:tabular-nums;white-space:nowrap;}
27127 .git-commit-chip{cursor:help;}
27128 .commit-tip{position:fixed;z-index:9999;display:none;background:var(--text);color:var(--bg);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;font-weight:600;letter-spacing:.02em;padding:7px 11px;border-radius:8px;box-shadow:0 6px 20px rgba(0,0,0,0.28);pointer-events:none;white-space:nowrap;}
27129 .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;}
27130 .btn:hover{background:var(--line);}
27131 .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
27132 .btn.primary:hover{opacity:.9;}
27133 .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;}
27134 .btn-back:hover{background:var(--line);}
27135 .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;}
27136 .export-btn:hover{background:var(--line);}
27137 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
27138 .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
27139 .no-report{color:var(--muted);font-size:11px;font-style:italic;}
27140 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
27141 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
27142 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
27143 .pagination-info{font-size:13px;color:var(--muted);}
27144 .pagination-btns{display:flex;gap:6px;}
27145 .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;}
27146 .pg-btn:hover:not(:disabled){background:var(--line);}
27147 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
27148 .pg-btn:disabled{opacity:.35;cursor:default;}
27149 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
27150 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
27151 .stat-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px 16px;position:relative;cursor:default;transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1);}
27152 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
27153 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
27154 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
27155 .stat-chip-tip{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%) translateY(-7px);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 .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1);z-index:200;box-shadow:0 4px 14px rgba(0,0,0,0.2);}
27156 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
27157 .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
27158 .stat-chip-exact{position:absolute;bottom:6px;right:10px;font-size:12px;font-weight:600;color:var(--muted);font-variant-numeric:tabular-nums;line-height:1;}
27159 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
27160 .site-footer a{color:var(--muted);}
27161 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
27162 .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%;}
27163 .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
27164 .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;}
27165 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
27166 .toast-error{display:flex;align-items:center;gap:10px;background:#fde8e8;border:1px solid #f5a3a3;border-radius:10px;padding:10px 16px;margin-bottom:14px;font-size:13px;color:#7a1a1a;font-weight:600;}
27167 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
27168 .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;}
27169 .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;}
27170 .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;}
27171 @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));}}
27172 .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;white-space:nowrap;text-decoration:none;}.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;}
27173 .watched-bar{display:flex;align-items:center;gap:10px;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:8px 12px;flex-wrap:wrap;margin-bottom:14px;position:relative;z-index:1;}
27174 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
27175 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
27176 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
27177 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
27178 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
27179 .watched-chip{display:inline-flex;align-items:center;gap:4px;background:var(--surface-2);border:1px solid var(--line);border-radius:6px;padding:3px 6px 3px 8px;font-size:11px;max-width:300px;}
27180 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
27181 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
27182 .watched-chip-rm:hover{color:var(--oxide);}
27183 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
27184 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
27185 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
27186 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
27187 .rpt-btn{min-width:58px;justify-content:center;}
27188 .flex-row{display:flex;align-items:center;gap:8px;}
27189 .report-cell{overflow:visible;white-space:normal;}
27190 #history-table col:nth-child(1){width:185px;}
27191 #history-table col:nth-child(2){width:220px;}
27192 #history-table col:nth-child(3){width:100px;}
27193 #history-table col:nth-child(4){width:72px;}
27194 #history-table col:nth-child(5){width:82px;}
27195 #history-table col:nth-child(6){width:82px;}
27196 #history-table col:nth-child(7){width:65px;}
27197 #history-table col:nth-child(8){width:90px;}
27198 #history-table col:nth-child(9){width:85px;}
27199 #history-table col:nth-child(10){width:115px;}
27200 #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
27201 .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
27202 .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
27203 .submod-details summary::-webkit-details-marker{display:none;}
27204.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
27205 .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;}
27206 .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
27207 body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
27208 </style>
27209</head>
27210<body>
27211 <div class="background-watermarks" aria-hidden="true">
27212 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27213 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27214 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27215 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27216 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27217 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27218 </div>
27219 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
27220 <div class="top-nav">
27221 <div class="top-nav-inner">
27222 <a class="brand" href="/">
27223 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
27224 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
27225 </a>
27226 <div class="nav-right">
27227 <a class="nav-pill" href="/">Home</a>
27228 <div class="nav-dropdown">
27229 <a href="/view-reports" class="nav-dropdown-btn" style="background:rgba(255,255,255,0.22);">View Reports <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></a>
27230 <div class="nav-dropdown-menu">
27231 <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
27232 </div>
27233 </div>
27234 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
27235 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
27236 <div class="nav-dropdown">
27237 <a href="/git-browser" class="nav-dropdown-btn">Git Browser <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></a>
27238 <div class="nav-dropdown-menu">
27239 <a href="/integrations"><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>Integrations</a>
27240 </div>
27241 </div>
27242 <div class="server-status-wrap" id="server-status-wrap">
27243 <div class="nav-pill server-online-pill" id="server-status-pill">
27244 <span class="status-dot" id="status-dot"></span>
27245 <span id="server-status-label">Server</span>
27246 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
27247 </div>
27248 <div class="server-status-tip">
27249 OxideSLOC is running — accessible on your network.
27250 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
27251 </div>
27252 </div>
27253 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
27254 <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
27255 </button>
27256 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
27257 <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>
27258 <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>
27259 </button>
27260 </div>
27261 </div>
27262 </div>
27263
27264 <div class="page">
27265 {% if let Some(err) = browse_error %}
27266 <div class="toast-error">
27267 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.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>
27268 {{ err }}
27269 </div>
27270 {% endif %}
27271 {% if linked_count > 0 %}
27272 <div class="toast-success">
27273 <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>
27274 {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
27275 </div>
27276 {% endif %}
27277 <div class="watched-bar">
27278 <div class="watched-bar-left">
27279 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="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>
27280 <span class="watched-label">Watched Folders</span>
27281 <div class="watched-chips">
27282 {% if server_mode %}
27283 <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
27284 {% else %}
27285 {% for dir in watched_dirs %}
27286 <span class="watched-chip">
27287 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
27288 <form method="POST" action="/watched-dirs/remove" style="display:contents">
27289 <input type="hidden" name="folder_path" value="{{ dir }}">
27290 <input type="hidden" name="redirect_to" value="/view-reports">
27291 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
27292 </form>
27293 </span>
27294 {% endfor %}
27295 {% if watched_dirs.is_empty() %}
27296 <span class="watched-none">No folders watched — click Choose to add one</span>
27297 {% endif %}
27298 {% endif %}
27299 </div>
27300 </div>
27301 {% if !server_mode %}
27302 <div class="watched-bar-right">
27303 <button type="button" class="btn" id="add-watched-btn">
27304 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
27305 Choose
27306 </button>
27307 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
27308 <input type="hidden" name="redirect_to" value="/view-reports">
27309 <button type="submit" class="btn">↻ Refresh</button>
27310 </form>
27311 </div>
27312 {% endif %}
27313 </div>
27314 <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
27315 <div class="scan-overlay-card">
27316 <div class="scan-spinner"></div>
27317 <div class="scan-overlay-text">Scanning folder…</div>
27318 <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
27319 </div>
27320 </div>
27321 <style>
27322 .scan-overlay{position:fixed;inset:0;z-index:12000;display:none;align-items:center;justify-content:center;background:rgba(20,12,8,0.5);backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);}
27323 .scan-overlay.active{display:flex;}
27324 .scan-overlay-card{background:var(--surface);border:1px solid var(--line-strong);border-radius:16px;padding:26px 38px;display:flex;flex-direction:column;align-items:center;gap:12px;box-shadow:0 24px 60px rgba(0,0,0,0.35);max-width:340px;text-align:center;}
27325 .scan-spinner{width:42px;height:42px;border-radius:50%;border:4px solid var(--line);border-top-color:var(--oxide);animation:scanSpin 0.8s linear infinite;}
27326 @keyframes scanSpin{to{transform:rotate(360deg);}}
27327 .scan-overlay-text{font-size:15px;font-weight:800;color:var(--text);}
27328 .scan-overlay-sub{font-size:12px;color:var(--muted);line-height:1.5;}
27329 </style>
27330 {% if total_scans > 0 %}
27331 <div class="summary-strip">
27332 <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>
27333 <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>
27334 <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>
27335 <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>
27336 </div>
27337 {% endif %}
27338
27339 <section class="panel">
27340 <div class="panel-header">
27341 <div>
27342 <h1>View Reports</h1>
27343 <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
27344 {% if server_mode %}<p class="panel-meta" style="margin-top:4px;color:var(--muted);">Showing all scans from all users on this server — scan history is shared across authenticated sessions.</p>{% endif %}
27345 </div>
27346 <div class="flex-row">
27347 <button type="button" class="export-btn" id="export-csv-btn">
27348 <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>
27349 Export CSV
27350 </button>
27351 <button type="button" class="export-btn" id="export-xls-btn">
27352 <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>
27353 Export Excel
27354 </button>
27355 </div>
27356 </div>
27357
27358 {% if entries.is_empty() %}
27359 <div class="empty-state">
27360 <strong>No reports with viewable HTML yet</strong>
27361 Run a new analysis from the <a href="/scan">scan page</a>, or click <strong>Choose</strong> above to watch a folder containing saved reports.
27362 </div>
27363 {% else %}
27364 <div class="filter-row">
27365 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name…">
27366 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
27367 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
27368 </div>
27369 <div class="table-wrap">
27370 <table id="history-table">
27371 <colgroup>
27372 <col><col><col><col><col><col><col><col><col><col>
27373 </colgroup>
27374 <thead>
27375 <tr id="history-thead">
27376 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27377 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27378 <th>Run ID<div class="col-resize-handle"></div></th>
27379 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27380 <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>
27381 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27382 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27383 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27384 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27385 <th>Report<div class="col-resize-handle"></div></th>
27386 </tr>
27387 </thead>
27388 <tbody id="history-tbody">
27389 {% for entry in entries %}
27390 <tr class="history-row" data-run="{{ entry.run_id }}"
27391 data-timestamp="{{ entry.timestamp }}"
27392 data-project="{{ entry.project_label }}"
27393 data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
27394 data-skipped="{{ entry.files_skipped }}"
27395 data-comments="{{ entry.comment_lines }}"
27396 data-blank="{{ entry.blank_lines }}"
27397 data-physical="{{ entry.total_physical_lines }}"
27398 data-functions="{{ entry.functions }}"
27399 data-classes="{{ entry.classes }}"
27400 data-variables="{{ entry.variables }}"
27401 data-imports="{{ entry.imports }}"
27402 data-tests="{{ entry.test_count }}"
27403 data-branch="{{ entry.git_branch }}"
27404 data-commit="{{ entry.git_commit }}"
27405 data-has-json="{{ entry.has_json }}"
27406 data-html-url="/runs/html/{{ entry.run_id }}">
27407 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
27408 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
27409 <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
27410 <td><span class="metric-num">{{ entry.files_analyzed }}</span><div class="metric-secondary"><span class="skipped-pill">{{ entry.files_skipped|commas }} skipped</span></div></td>
27411 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
27412 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
27413 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
27414 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">—</span>{% endif %}</td>
27415 <td>{% if !entry.git_commit.is_empty() %}<span class="git-chip git-commit-chip" data-full-commit="{{ entry.git_commit_long }}">{{ entry.git_commit }}</span>{% else %}<span class="metric-secondary">—</span>{% endif %}</td>
27416 <td class="report-cell">
27417 <div class="actions-cell">
27418 {% if entry.has_json %}<a class="btn primary rpt-btn" href="/runs/result/{{ entry.run_id }}" target="_blank" rel="noopener" title="Open full interactive result report">View</a>{% else %}<a class="btn primary rpt-btn" href="/runs/html/{{ entry.run_id }}" target="_blank" rel="noopener" title="View HTML report">View</a>{% endif %}
27419 {% if entry.has_pdf %}<a class="btn primary rpt-btn" href="/runs/pdf/{{ entry.run_id }}" target="_blank" rel="noopener" title="View PDF report">PDF</a>{% endif %}
27420 </div>
27421 {% if !entry.submodule_links.is_empty() %}
27422 <details class="submod-details">
27423 <summary>↳ {{ entry.submodule_links.len() }} submodule(s)</summary>
27424 <div class="submod-link-list">
27425 {% for sub in entry.submodule_links %}
27426 <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
27427 {% endfor %}
27428 </div>
27429 </details>
27430 {% endif %}
27431 </td>
27432 </tr>
27433 {% endfor %}
27434 </tbody>
27435 </table>
27436 </div>
27437 <div class="pagination">
27438 <span class="pagination-info" id="pagination-info"></span>
27439 <div class="pagination-btns" id="pagination-btns"></div>
27440 <div class="flex-row">
27441 <span class="per-page-label">Show</span>
27442 <select class="per-page" id="per-page-sel">
27443 <option value="10">10 per page</option>
27444 <option value="25" selected>25 per page</option>
27445 <option value="50">50 per page</option>
27446 <option value="100">100 per page</option>
27447 </select>
27448 <span class="per-page-label" id="page-range-label"></span>
27449 </div>
27450 </div>
27451 {% endif %}
27452 </section>
27453 </div>
27454
27455 <footer class="site-footer">
27456 local code analysis - metrics, history and reports
27457 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
27458 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
27459 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
27460 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
27461 · <a href="/api-docs" rel="noopener">REST API</a>
27462 </footer>
27463
27464 <script nonce="{{ csp_nonce }}">
27465 (function () {
27466 // ── Theme ──────────────────────────────────────────────────────────────
27467 var storageKey = 'oxide-sloc-theme';
27468 var body = document.body;
27469 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
27470 var toggle = document.getElementById('theme-toggle');
27471 if (toggle) toggle.addEventListener('click', function () {
27472 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
27473 body.classList.toggle('dark-theme', next === 'dark');
27474 try { localStorage.setItem(storageKey, next); } catch(e) {}
27475 });
27476
27477 // ── State ─────────────────────────────────────────────────────────────
27478 var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
27479 var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
27480 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
27481
27482 // Aggregate stats from first (most recent) row
27483 if (allRows.length) {
27484 var first = allRows[0];
27485 function slocFmt(n){var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}
27486 function setChipVal(id,n){var el=document.getElementById(id);if(!el)return;var compact=slocFmt(n),full=Number(n).toLocaleString();el.innerHTML=compact+(compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'');}
27487 setChipVal('agg-code', first.dataset.code);
27488 setChipVal('agg-files', first.dataset.files);
27489 var projects = {}; allRows.forEach(function(r){var p=r.dataset.project||'';if(p)projects[p]=true;});
27490 var pe=document.getElementById('agg-projects'); if(pe) pe.textContent=Object.keys(projects).filter(Boolean).length;
27491 Array.prototype.forEach.call(document.querySelectorAll('#history-tbody .metric-num'), function(el) { var n = Number(el.textContent); if (!isNaN(n) && el.textContent.trim() !== '') el.textContent = n.toLocaleString(); });
27492 }
27493
27494 // ── Branch filter population ──────────────────────────────────────────
27495 (function() {
27496 var branches = {};
27497 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
27498 var sel = document.getElementById('branch-filter');
27499 if (sel) Object.keys(branches).sort().forEach(function(b) {
27500 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
27501 });
27502 })();
27503
27504 // ── Filter ────────────────────────────────────────────────────────────
27505 function getFilteredRows() {
27506 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
27507 var branch = ((document.getElementById('branch-filter') || {}).value || '');
27508 return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
27509 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
27510 if (branch && (r.dataset.branch || '') !== branch) return false;
27511 return true;
27512 });
27513 }
27514
27515 // ── Pagination ────────────────────────────────────────────────────────
27516 function renderPage() {
27517 var filtered = getFilteredRows();
27518 var total = filtered.length;
27519 var totalPages = Math.max(1, Math.ceil(total / perPage));
27520 currentPage = Math.min(currentPage, totalPages);
27521 var start = (currentPage - 1) * perPage;
27522 var end = Math.min(start + perPage, total);
27523 var shown = {};
27524 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
27525 Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
27526 r.style.display = shown[r.dataset.run] ? '' : 'none';
27527 });
27528 var rl = document.getElementById('page-range-label');
27529 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
27530 var info = document.getElementById('pagination-info');
27531 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
27532 var btns = document.getElementById('pagination-btns');
27533 if (!btns) return;
27534 btns.innerHTML = '';
27535 function makeBtn(lbl, pg, active, disabled) {
27536 var b = document.createElement('button');
27537 b.className = 'pg-btn' + (active ? ' active' : '');
27538 b.textContent = lbl; b.disabled = disabled;
27539 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
27540 return b;
27541 }
27542 btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
27543 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
27544 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
27545 btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
27546 }
27547
27548 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
27549 window.applyFilters = function() { currentPage = 1; renderPage(); };
27550
27551 // ── Sorting ───────────────────────────────────────────────────────────
27552 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
27553 function doSort(col, type, order) {
27554 var tbody = document.getElementById('history-tbody');
27555 if (!tbody) return;
27556 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
27557 rows.sort(function(a, b) {
27558 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
27559 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
27560 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
27561 return va < vb ? 1 : va > vb ? -1 : 0;
27562 });
27563 rows.forEach(function(r) { tbody.appendChild(r); });
27564 currentPage = 1; renderPage();
27565 }
27566 sortHeaders.forEach(function(th) {
27567 th.addEventListener('click', function(e) {
27568 if (e.target.classList.contains('col-resize-handle')) return;
27569 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
27570 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
27571 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
27572 th.classList.add('sort-' + sortOrder);
27573 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
27574 doSort(col, type, sortOrder);
27575 });
27576 });
27577
27578 // ── Column resize ─────────────────────────────────────────────────────
27579 (function() {
27580 var table = document.getElementById('history-table');
27581 if (!table) return;
27582 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
27583 var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
27584 ths.forEach(function(th, i) {
27585 var handle = th.querySelector('.col-resize-handle');
27586 if (!handle || !cols[i]) return;
27587 var startX, startW;
27588 handle.addEventListener('mousedown', function(e) {
27589 e.stopPropagation(); e.preventDefault();
27590 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
27591 handle.classList.add('dragging');
27592 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
27593 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
27594 document.addEventListener('mousemove', onMove);
27595 document.addEventListener('mouseup', onUp);
27596 });
27597 });
27598 })();
27599
27600 // ── Full-commit hover tooltip ─────────────────────────────────────────
27601 // The commit chips live inside an overflow:auto table wrapper, which would
27602 // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
27603 // (escaping the scroll container) and follow the cursor. Event delegation
27604 // keeps it working after pagination/sorting re-renders the rows.
27605 (function() {
27606 var tip = document.createElement('div');
27607 tip.className = 'commit-tip';
27608 tip.setAttribute('role', 'tooltip');
27609 document.body.appendChild(tip);
27610 var shown = false;
27611 function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
27612 function place(e) {
27613 var pad = 14, r = tip.getBoundingClientRect();
27614 var x = e.clientX + pad, y = e.clientY + pad;
27615 if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
27616 if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
27617 tip.style.left = x + 'px'; tip.style.top = y + 'px';
27618 }
27619 function hide() { tip.style.display = 'none'; shown = false; }
27620 document.addEventListener('mouseover', function(e) {
27621 var chip = chipFrom(e.target);
27622 if (!chip) return;
27623 var full = chip.getAttribute('data-full-commit');
27624 if (!full) return;
27625 tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
27626 });
27627 document.addEventListener('mousemove', function(e) {
27628 if (!shown) return;
27629 if (chipFrom(e.target)) place(e); else hide();
27630 });
27631 document.addEventListener('mouseout', function(e) {
27632 if (chipFrom(e.target)) hide();
27633 });
27634 })();
27635
27636 // ── Reset view ────────────────────────────────────────────────────────
27637 window.resetView = function() {
27638 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
27639 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
27640 sortCol = null; sortOrder = 'asc';
27641 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
27642 var tbody = document.getElementById('history-tbody');
27643 if (tbody) {
27644 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
27645 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
27646 rows.forEach(function(r) { tbody.appendChild(r); });
27647 }
27648 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
27649 var table = document.getElementById('history-table');
27650 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
27651 currentPage = 1; renderPage();
27652 };
27653
27654 renderPage();
27655
27656 // ── Export helpers ────────────────────────────────────────────────────
27657 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
27658 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
27659 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);}
27660 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;');}
27661 function slocXlsx(fname,sheet,hdrs,rows){
27662 var enc=new TextEncoder();
27663 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;}
27664 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;}
27665 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
27666 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
27667 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
27668 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;}
27669 function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
27670 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];}
27671 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
27672 // Style 0=normal, 1=header(orange fill/white bold), 2=number(#,##0 right-aligned), 3=text(@)
27673 var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
27674 +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
27675 +'<fonts count="2">'
27676 +'<font><sz val="11"/><name val="Calibri"/></font>'
27677 +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
27678 +'</fonts>'
27679 +'<fills count="3">'
27680 +'<fill><patternFill patternType="none"/></fill>'
27681 +'<fill><patternFill patternType="gray125"/></fill>'
27682 +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
27683 +'</fills>'
27684 +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
27685 +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
27686 +'<cellXfs count="4">'
27687 +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
27688 +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27689 +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
27690 +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
27691 +'</cellXfs>'
27692 +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
27693 +'</styleSheet>';
27694 var rx='<row r="1">';
27695 hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
27696 rx+='</row>';
27697 rows.forEach(function(row,ri){
27698 var rn=ri+2;rx+='<row r="'+rn+'">';
27699 row.forEach(function(cell,c){
27700 var ref=colRef(c,rn),sv=String(cell==null?'':cell);
27701 var isNum=sv!==''&&!isNaN(Number(sv))&&isFinite(Number(sv))&&/^[+\-]?\d/.test(sv);
27702 var isPct=!isNum&&/^\d+\.?\d*%$/.test(sv);
27703 if(isNum){rx+='<c r="'+ref+'" s="2"><v>'+xe(sv)+'</v></c>';}
27704 else if(isPct){rx+='<c r="'+ref+'" t="s" s="3"><v>'+S(sv)+'</v></c>';}
27705 else{rx+='<c r="'+ref+'" t="s"><v>'+S(sv)+'</v></c>';}
27706 });
27707 rx+='</row>';
27708 });
27709 var lastCol=hdrs.length,lastRow=rows.length+1;
27710 var tableRef='A1:'+colNm(lastCol)+lastRow;
27711 var tableXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27712 +'<table xmlns="'+sns+'" id="1" name="ScanHistory" displayName="ScanHistory" ref="'+tableRef+'" totalsRowShown="0">'
27713 +'<autoFilter ref="'+tableRef+'"/>'
27714 +'<tableColumns count="'+lastCol+'">'
27715 +hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
27716 +'</tableColumns>'
27717 +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
27718 +'</table>';
27719 var wsRels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27720 +'<Relationships xmlns="'+pns+'relationships">'
27721 +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table1.xml"/>'
27722 +'</Relationships>';
27723 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>';
27724 var sh='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
27725 +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
27726 +'<sheetFormatPr defaultRowHeight="15"/><sheetData>'+rx+'</sheetData>'
27727 +'<tableParts count="1"><tablePart r:id="rId1"/></tableParts>'
27728 +'</worksheet>';
27729 var F={
27730 '[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"/><Override PartName="/xl/tables/table1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/></Types>',
27731 '_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>',
27732 '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>',
27733 '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>',
27734 'xl/styles.xml':stl,
27735 'xl/sharedStrings.xml':ssXml,
27736 'xl/worksheets/sheet1.xml':sh,
27737 'xl/worksheets/_rels/sheet1.xml.rels':wsRels,
27738 'xl/tables/table1.xml':tableXml
27739 };
27740 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','xl/worksheets/_rels/sheet1.xml.rels','xl/tables/table1.xml'];
27741 var zparts=[],zcds=[],zoff=0,znf=0;
27742 order.forEach(function(name){
27743 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
27744 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]);
27745 var entry=new Uint8Array(lha.length+nb.length+sz);
27746 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
27747 zparts.push(entry);
27748 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));
27749 var cde=new Uint8Array(cda.length+nb.length);
27750 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
27751 zcds.push(cde);zoff+=entry.length;znf++;
27752 });
27753 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
27754 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]);
27755 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
27756 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
27757 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
27758 zout.set(new Uint8Array(ea),zpos);
27759 slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
27760 }
27761
27762 // Multi-sheet XLSX builder for the scan-history export.
27763 // Styles: 0=normal 1=col-header(orange/white bold) 2=number(right) 3=section 4=bold-label 5=number(left) 6=text(@)
27764 function slocXlsxMulti(fname,sheets){
27765 var enc=new TextEncoder();
27766 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;}
27767 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;}
27768 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
27769 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
27770 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
27771 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];}
27772 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;}
27773 function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
27774 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
27775 var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
27776 +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
27777 +'<fonts count="3">'
27778 +'<font><sz val="11"/><name val="Calibri"/></font>'
27779 +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
27780 +'<font><sz val="11"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font>'
27781 +'</fonts>'
27782 +'<fills count="4">'
27783 +'<fill><patternFill patternType="none"/></fill>'
27784 +'<fill><patternFill patternType="gray125"/></fill>'
27785 +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
27786 +'<fill><patternFill patternType="solid"><fgColor rgb="FFFAF0E6"/><bgColor indexed="64"/></patternFill></fill>'
27787 +'</fills>'
27788 +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
27789 +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
27790 +'<cellXfs count="7">'
27791 +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
27792 +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27793 +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
27794 +'<xf numFmtId="0" fontId="2" fillId="3" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27795 +'<xf numFmtId="0" fontId="2" fillId="0" borderId="0" xfId="0" applyFont="1"/>'
27796 +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="left"/></xf>'
27797 +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
27798 +'</cellXfs>'
27799 +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
27800 +'</styleSheet>';
27801 var wsXmls=[],tableCounter=0,tableXmls={},wsRelsXmls={};
27802 sheets.forEach(function(sh,sheetIdx){
27803 var rx='<row r="1">';
27804 sh.hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
27805 rx+='</row>';
27806 var rn=2;
27807 sh.rows.forEach(function(row){
27808 if(!row||row.length===0){rx+='<row r="'+rn+'"/>';rn++;return;}
27809 if(row.length===1&&row[0]&&typeof row[0]==='object'&&row[0]._sec){
27810 rx+='<row r="'+rn+'">';
27811 rx+='<c r="'+colRef(0,rn)+'" t="s" s="3"><v>'+S(row[0].v)+'</v></c>';
27812 for(var ec=1;ec<sh.hdrs.length;ec++){rx+='<c r="'+colRef(ec,rn)+'" s="3"/>';}
27813 rx+='</row>';rn++;return;
27814 }
27815 rx+='<row r="'+rn+'">';
27816 row.forEach(function(cell,c){
27817 var ref=colRef(c,rn);
27818 if(cell===null||cell===undefined||cell===''){rx+='<c r="'+ref+'"/>';return;}
27819 if(typeof cell==='object'&&cell!==null){
27820 var cv=cell.v,cs=cell.s!=null?cell.s:0;
27821 if(typeof cv==='number'){rx+='<c r="'+ref+'" s="'+cs+'"><v>'+xe(cv)+'</v></c>';}
27822 else{rx+='<c r="'+ref+'" t="s" s="'+cs+'"><v>'+S(cv)+'</v></c>';}
27823 return;
27824 }
27825 if(typeof cell==='number'){rx+='<c r="'+ref+'" s="2"><v>'+xe(cell)+'</v></c>';return;}
27826 rx+='<c r="'+ref+'" t="s"><v>'+S(cell)+'</v></c>';
27827 });
27828 rx+='</row>';rn++;
27829 });
27830 var cw='';
27831 if(sh.colWidths&&sh.colWidths.length>0){
27832 cw='<cols>';
27833 sh.colWidths.forEach(function(w,i){cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';});
27834 cw+='</cols>';
27835 }
27836 var tblParts='';
27837 if(!sh.isKv&&sh.hdrs.length>0&&sh.rows.length>0){
27838 tableCounter++;
27839 var tc=tableCounter,colCount=sh.hdrs.length,rowCount=sh.rows.length+1;
27840 var tRef='A1:'+colNm(colCount)+rowCount;
27841 tableXmls['xl/tables/table'+tc+'.xml']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27842 +'<table xmlns="'+sns+'" id="'+tc+'" name="Table'+tc+'" displayName="Table'+tc+'" ref="'+tRef+'" totalsRowShown="0">'
27843 +'<autoFilter ref="'+tRef+'"/>'
27844 +'<tableColumns count="'+colCount+'">'
27845 +sh.hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
27846 +'</tableColumns>'
27847 +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
27848 +'</table>';
27849 wsRelsXmls['xl/worksheets/_rels/sheet'+(sheetIdx+1)+'.xml.rels']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27850 +'<Relationships xmlns="'+pns+'relationships">'
27851 +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table'+tc+'.xml"/>'
27852 +'</Relationships>';
27853 tblParts='<tableParts count="1"><tablePart r:id="rId1"/></tableParts>';
27854 }
27855 wsXmls.push('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
27856 +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
27857 +'<sheetFormatPr defaultRowHeight="15"/>'+cw+'<sheetData>'+rx+'</sheetData>'+tblParts+'</worksheet>');
27858 });
27859 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>';
27860 var ctOver=sheets.map(function(_,i){return'<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}).join('');
27861 var ctTable=Object.keys(tableXmls).map(function(k){return'<Override PartName="/'+k+'" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';}).join('');
27862 var ctXml='<?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"/>'+ctOver+ctTable+'<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>';
27863 var wbSh=sheets.map(function(sh,i){return'<sheet name="'+xe(sh.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}).join('');
27864 var wbXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="'+sns+'" xmlns:r="'+ons+'relationships"><sheets>'+wbSh+'</sheets></workbook>';
27865 var wbR=sheets.map(function(_,i){return'<Relationship Id="rId'+(i+1)+'" Type="'+ons+'relationships/worksheet" Target="worksheets/sheet'+(i+1)+'.xml"/>';}).join('');
27866 wbR+='<Relationship Id="rId'+(sheets.length+1)+'" Type="'+ons+'relationships/styles" Target="styles.xml"/>'
27867 +'<Relationship Id="rId'+(sheets.length+2)+'" Type="'+ons+'relationships/sharedStrings" Target="sharedStrings.xml"/>';
27868 var wbRXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships">'+wbR+'</Relationships>';
27869 var F={'[Content_Types].xml':ctXml,'_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>','xl/workbook.xml':wbXml,'xl/_rels/workbook.xml.rels':wbRXml,'xl/styles.xml':stl,'xl/sharedStrings.xml':ssXml};
27870 var order=['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml'];
27871 sheets.forEach(function(_,i){var k='xl/worksheets/sheet'+(i+1)+'.xml';F[k]=wsXmls[i];order.push(k);});
27872 Object.keys(wsRelsXmls).forEach(function(k){F[k]=wsRelsXmls[k];order.push(k);});
27873 Object.keys(tableXmls).forEach(function(k){F[k]=tableXmls[k];order.push(k);});
27874 var zparts=[],zcds=[],zoff=0,znf=0;
27875 order.forEach(function(name){var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);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]);var entry=new Uint8Array(lha.length+nb.length+sz);entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);zparts.push(entry);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));var cde=new Uint8Array(cda.length+nb.length);cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);zcds.push(cde);zoff+=entry.length;znf++;});
27876 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
27877 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]);
27878 var tot=zoff+cdSz+ea.length,zout=new Uint8Array(tot),zpos=0;
27879 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
27880 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
27881 zout.set(new Uint8Array(ea),zpos);
27882 slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
27883 }
27884
27885 var LANG_NAMES={'c':'C','cpp':'C++','c_sharp':'C#','go':'Go','java':'Java','java_script':'JavaScript','python':'Python','rust':'Rust','shell':'Shell','power_shell':'PowerShell','type_script':'TypeScript','assembly':'Assembly','clojure':'Clojure','css':'CSS','dart':'Dart','dockerfile':'Dockerfile','elixir':'Elixir','erlang':'Erlang','f_sharp':'F#','groovy':'Groovy','haskell':'Haskell','html':'HTML','julia':'Julia','kotlin':'Kotlin','lua':'Lua','makefile':'Makefile','nim':'Nim','objective_c':'Objective-C','ocaml':'OCaml','perl':'Perl','php':'PHP','r':'R','ruby':'Ruby','scala':'Scala','scss':'SCSS','sql':'SQL','svelte':'Svelte','swift':'Swift','vue':'Vue','xml':'XML','zig':'Zig','solidity':'Solidity','protobuf':'Protocol Buffers','hcl':'HCL/Terraform','graph_ql':'GraphQL','ada':'Ada','vhdl':'VHDL','verilog':'Verilog/SystemVerilog','tcl':'Tcl','pascal':'Pascal/Delphi','visual_basic':'Visual Basic','lisp':'Lisp/Scheme','fortran':'Fortran','nix':'Nix','crystal':'Crystal','d':'D','glsl':'GLSL/HLSL','cmake':'CMake','elm':'Elm','awk':'Awk'};
27886 function langName(k){return LANG_NAMES[k]||String(k||'').replace(/_/g,' ')||'(unknown)';}
27887
27888 var _hh = ['Timestamp','Project','Run ID','Physical Lines','Code Lines','Comments','Blank Lines','Files Analyzed','Files Skipped','Functions','Classes','Variables','Imports','Tests','Code Density','Branch','Commit'];
27889 function getHistoryRows(){
27890 var r=[];
27891 document.querySelectorAll('#history-tbody .history-row').forEach(function(tr){
27892 var code=Number(tr.getAttribute('data-code'))||0;
27893 var phys=Number(tr.getAttribute('data-physical'))||0;
27894 var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
27895 r.push([
27896 tr.getAttribute('data-timestamp')||'',
27897 tr.getAttribute('data-project')||'',
27898 tr.getAttribute('data-run')||'',
27899 tr.getAttribute('data-physical')||'',
27900 tr.getAttribute('data-code')||'',
27901 tr.getAttribute('data-comments')||'',
27902 tr.getAttribute('data-blank')||'',
27903 tr.getAttribute('data-files')||'',
27904 tr.getAttribute('data-skipped')||'',
27905 tr.getAttribute('data-functions')||'',
27906 tr.getAttribute('data-classes')||'',
27907 tr.getAttribute('data-variables')||'',
27908 tr.getAttribute('data-imports')||'',
27909 tr.getAttribute('data-tests')||'',
27910 dens,
27911 tr.getAttribute('data-branch')||'',
27912 tr.getAttribute('data-commit')||''
27913 ]);
27914 });
27915 return r;
27916 }
27917 window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
27918 window.exportHistoryXls = function(){
27919 var histRows=getHistoryRows();
27920 function toN(v){var n=Number(v);return isNaN(n)||v===''?0:n;}
27921 var xlsxRows=histRows.map(function(r){return[r[0],r[1],r[2],toN(r[3]),toN(r[4]),toN(r[5]),toN(r[6]),toN(r[7]),toN(r[8]),toN(r[9]),toN(r[10]),toN(r[11]),toN(r[12]),toN(r[13]),{v:r[14],s:6},r[15],r[16]];});
27922 var histSheet={name:'Scan History',hdrs:_hh,rows:xlsxRows,colWidths:[18,14,22,14,12,12,12,12,12,11,10,10,10,8,13,10,12]};
27923 var jsonRow=document.querySelector('#history-tbody .history-row[data-has-json="true"]');
27924 if(!jsonRow){slocXlsxMulti('scan-history.xlsx',[histSheet]);return;}
27925 var runId=jsonRow.getAttribute('data-run')||'';
27926 var proj=(jsonRow.getAttribute('data-project')||'Latest').substring(0,18);
27927 function sn(suffix){var p=proj.substring(0,Math.max(1,28-suffix.length));return p+' - '+suffix;}
27928 fetch('/runs/json/'+runId)
27929 .then(function(r){if(!r.ok)throw new Error('no json');return r.json();})
27930 .then(function(run){
27931 var tot=run.summary_totals||{};
27932 var phys=Number(tot.total_physical_lines)||0,code=Number(tot.code_lines)||0;
27933 var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
27934 function B(v){return{v:v,s:4};}
27935 function N(v){return{v:typeof v==='number'?v:Number(v),s:5};}
27936 var sumRows=[
27937 [{_sec:true,v:'RUN INFORMATION'}],
27938 [B('Run ID'),(run.tool&&run.tool.run_id)||''],
27939 [B('Timestamp'),(run.tool&&run.tool.timestamp_utc)||''],
27940 [B('Project'),(run.effective_configuration&&run.effective_configuration.reporting&&run.effective_configuration.reporting.report_title)||proj],
27941 [B('Branch'),run.git_branch||''],
27942 [B('Commit'),run.git_commit_long||run.git_commit_short||''],
27943 [B('OS'),(run.environment&&(run.environment.operating_system+' / '+run.environment.architecture))||''],
27944 [B('Files Analyzed'),N(tot.files_analyzed)],
27945 [B('Files Skipped'),N(tot.files_skipped)],
27946 [],
27947 [{_sec:true,v:'CODE METRICS'}],
27948 [B('Physical Lines'),N(phys)],
27949 [B('Code Lines'),N(code)],
27950 [B('Comments'),N(tot.comment_lines)],
27951 [B('Blank Lines'),N(tot.blank_lines)],
27952 [B('Mixed Separate'),N(tot.mixed_lines_separate)],
27953 [B('Functions'),N(tot.functions)],
27954 [B('Classes / Types'),N(tot.classes)],
27955 [B('Variables'),N(tot.variables)],
27956 [B('Imports'),N(tot.imports)],
27957 [B('Tests'),N(tot.test_count)],
27958 [B('Assertions'),N(tot.test_assertion_count)],
27959 [B('Test Suites'),N(tot.test_suite_count)],
27960 [B('Code Density'),{v:dens,s:6}],
27961 [B('Tool Version'),'oxide-sloc '+((run.tool&&run.tool.version)||'')],
27962 ];
27963 var langHdrs=['Language','Files','Physical Lines','Code Lines','Code Density','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Test Suites'];
27964 var langRows=(run.totals_by_language||[]).map(function(l){
27965 var lp=Number(l.total_physical_lines)||0,lc=Number(l.code_lines)||0;
27966 var ld=lp>0?(lc/lp*100).toFixed(1)+'%':'0%';
27967 return [langName(l.language),l.files||0,lp,lc,{v:ld,s:6},l.comment_lines||0,l.blank_lines||0,l.functions||0,l.classes||0,l.variables||0,l.imports||0,l.test_count||0,l.test_assertion_count||0,l.test_suite_count||0];
27968 });
27969 var pfHdrs=['File','Language','Physical Lines','Code Lines','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Size (bytes)'];
27970 var pfRows=(run.per_file_records||[]).map(function(r){
27971 var rc=r.raw_line_categories||{},ec=r.effective_counts||{};
27972 return [r.relative_path,langName(r.language),rc.total_physical_lines||0,ec.code_lines||0,ec.comment_lines||0,ec.blank_lines||0,rc.functions||0,rc.classes||0,rc.variables||0,rc.imports||0,rc.test_count||0,rc.test_assertion_count||0,r.size_bytes||0];
27973 });
27974 var skHdrs=['File','Status','Size (bytes)'];
27975 var skRows=(run.skipped_file_records||[]).map(function(r){
27976 return [r.relative_path,String(r.status||'').replace(/_/g,' '),r.size_bytes||0];
27977 });
27978 slocXlsxMulti('scan-history.xlsx',[
27979 histSheet,
27980 {name:sn('Summary'),hdrs:['Field / Metric','Value'],rows:sumRows,colWidths:[22,44],isKv:true},
27981 {name:sn('Languages'),hdrs:langHdrs,rows:langRows,colWidths:[16,7,14,12,13,12,10,11,10,10,10,8,11,12]},
27982 {name:sn('Per-File'),hdrs:pfHdrs,rows:pfRows,colWidths:[48,12,14,12,12,10,11,10,10,10,8,11,12]},
27983 {name:sn('Skipped'),hdrs:skHdrs,rows:skRows,colWidths:[52,24,12]}
27984 ]);
27985 })
27986 .catch(function(){slocXlsxMulti('scan-history.xlsx',[histSheet]);});
27987 };
27988
27989 var csvBtn = document.getElementById('export-csv-btn');
27990 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
27991 var xlsBtn = document.getElementById('export-xls-btn');
27992 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
27993
27994 // ── Remaining CSP-safe event bindings ────────────────────────────────
27995 (function wireEvents() {
27996 var el;
27997 el = document.getElementById('reset-view-btn');
27998 if (el) el.addEventListener('click', window.resetView);
27999 el = document.getElementById('project-filter');
28000 if (el) el.addEventListener('input', window.applyFilters);
28001 el = document.getElementById('branch-filter');
28002 if (el) el.addEventListener('change', window.applyFilters);
28003 el = document.getElementById('per-page-sel');
28004 if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
28005 (function(){
28006 window.__scanOverlay=function(msg){var o=document.getElementById('scan-overlay');if(!o)return;if(o.parentNode!==document.body)document.body.appendChild(o);var t=o.querySelector('.scan-overlay-text');if(t&&msg)t.textContent=msg;o.classList.add('active');};
28007 document.addEventListener('submit',function(e){var f=e.target;if(!f||!f.getAttribute)return;var a=f.getAttribute('action')||'';if(a.indexOf('/watched-dirs/remove')!==-1){window.__scanOverlay('Updating watched folders');}else if(a.indexOf('/watched-dirs/')!==-1){window.__scanOverlay();}},true);
28008 })();
28009 el = document.getElementById('add-watched-btn');
28010 if (el) el.addEventListener('click', function() {
28011 fetch('/pick-directory?kind=reports')
28012 .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
28013 .then(function(data) {
28014 if (!data.cancelled && data.selected_path) {
28015 var form = document.createElement('form');
28016 form.method = 'POST';
28017 form.action = '/watched-dirs/add';
28018 var ri = document.createElement('input');
28019 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
28020 var fi = document.createElement('input');
28021 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
28022 form.appendChild(ri); form.appendChild(fi);
28023 document.body.appendChild(form);
28024 if (window.__scanOverlay) window.__scanOverlay();
28025 form.submit();
28026 }
28027 })
28028 .catch(function(e) { alert('Could not open folder picker: ' + e); });
28029 });
28030 })();
28031
28032 (function randomizeWatermarks() {
28033 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
28034 if (!wms.length) return;
28035 var placed = [];
28036 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;}
28037 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];}
28038 var half=Math.floor(wms.length/2);
28039 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;});
28040 })();
28041
28042 (function spawnCodeParticles() {
28043 var container = document.getElementById('code-particles');
28044 if (!container) return;
28045 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'];
28046 for (var i = 0; i < 38; i++) {
28047 (function(idx) {
28048 var el = document.createElement('span');
28049 el.className = 'code-particle';
28050 el.textContent = snippets[idx % snippets.length];
28051 var left = Math.random() * 94 + 2;
28052 var top = Math.random() * 88 + 6;
28053 var dur = (Math.random() * 10 + 9).toFixed(1);
28054 var delay = (Math.random() * 18).toFixed(1);
28055 var rot = (Math.random() * 26 - 13).toFixed(1);
28056 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
28057 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';
28058 container.appendChild(el);
28059 })(i);
28060 }
28061 })();
28062 })();
28063 </script>
28064 <script nonce="{{ csp_nonce }}">
28065 (function(){
28066 var S=[{n:'Classic',a:'#b85d33',b:'#7a371b'},{n:'Navy',a:'#283790',b:'#1e1e24'},{n:'Ember',a:'#ce5d3d',b:'#1e1e24'},{n:'Ocean',a:'#1f439b',b:'#1e1e24'},{n:'Royal',a:'#003184',b:'#1e1e24'}];
28067 function ap(s){document.documentElement.style.setProperty('--nav',s.a);document.documentElement.style.setProperty('--nav-2',s.b);try{localStorage.setItem('sloc-ns',JSON.stringify(s));}catch(e){}document.querySelectorAll('.scheme-swatch').forEach(function(x){x.classList.toggle('active',x.dataset.n===s.n);});}
28068 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
28069 function init(){
28070 var btn=document.getElementById('settings-btn');if(!btn)return;
28071 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
28072 m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
28073 document.body.appendChild(m);
28074 var g=document.getElementById('scheme-grid');
28075 if(g)S.forEach(function(s){var el=document.createElement('button');el.type='button';el.className='scheme-swatch';el.dataset.n=s.n;el.title=s.n;var p=document.createElement('div');p.className='scheme-preview';p.style.background='linear-gradient(135deg,'+s.a+','+s.b+')';var l=document.createElement('span');l.className='scheme-label';l.textContent=s.n;el.appendChild(p);el.appendChild(l);try{var c=JSON.parse(localStorage.getItem('sloc-ns'));if(c&&c.n===s.n)el.classList.add('active');}catch(e){}el.addEventListener('click',function(){ap(s);});g.appendChild(el);});
28076 var cl=document.getElementById('settings-close');
28077 window.tzAbbr=function(z){return{'America/Los_Angeles':'PT','America/Denver':'MT','America/Chicago':'CT','America/New_York':'ET','America/Anchorage':'AT','Pacific/Honolulu':'HT'}[z]||'PT';};window.tzCity=function(z){return{'America/Los_Angeles':'Los Angeles','America/Denver':'Denver','America/Chicago':'Chicago','America/New_York':'New York','America/Anchorage':'Anchorage','Pacific/Honolulu':'Honolulu'}[z]||'';};window.tzOffset=function(z){var r='';try{var p=new Intl.DateTimeFormat('en-US',{timeZone:z,timeZoneName:'longOffset'}).formatToParts(new Date());p.forEach(function(x){if(x.type==='timeZoneName')r=x.value.replace('GMT','UTC');});}catch(e){}return r;};window.tf24=function(){try{return localStorage.getItem('sloc-tf')!=='12';}catch(e){return true;}};window.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';var h24=window.tf24();try{var pts=new Intl.DateTimeFormat('en-US',{timeZone:tz,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:!h24}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});var t=v.hour+':'+v.minute;if(!h24&&v.dayPeriod)t+=' '+v.dayPeriod;return v.year+'-'+v.month+'-'+v.day+' '+t+' '+window.tzAbbr(tz);}catch(e){return'';}};window.enhanceTzOptions=function(sel){if(!sel)return;Array.prototype.forEach.call(sel.options,function(o){var base=o.textContent.split(' - ')[0];var city=window.tzCity(o.value);var off=window.tzOffset(o.value);o.textContent=base+(city?' - '+city:'')+(off?' - '+off:'');});};window.applyTz=function(tz){try{localStorage.setItem('sloc-tz',tz);}catch(e){}document.querySelectorAll('[data-utc-ms]').forEach(function(el){var ms=parseInt(el.getAttribute('data-utc-ms'),10);if(!isNaN(ms))el.textContent=window.fmtTz(ms,tz);});};window.applyTf=function(tf){try{localStorage.setItem('sloc-tf',tf);}catch(e){}var z;try{z=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){z='America/Los_Angeles';}window.applyTz(z);};var tzSel=document.getElementById('tz-select');window.enhanceTzOptions(tzSel);var storedTz;try{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){storedTz='America/Los_Angeles';}if(tzSel){tzSel.value=storedTz;tzSel.addEventListener('change',function(){window.applyTz(this.value);});}window.applyTz(storedTz);(function(){var tzp=document.getElementById('tz-select');if(!tzp||document.getElementById('tf-select')||!tzp.parentNode)return;var tw=document.createElement('div');tw.style.marginTop='10px';var tl=document.createElement('div');tl.className='settings-modal-label';tl.style.marginBottom='8px';tl.textContent='Time format';var tfSel=document.createElement('select');tfSel.className='tz-select';tfSel.id='tf-select';tfSel.innerHTML='<option value="24">24-hour (14:30)</option><option value="12">12-hour (2:30 PM)</option>';tw.appendChild(tl);tw.appendChild(tfSel);tzp.parentNode.appendChild(tw);var storedTf;try{storedTf=localStorage.getItem('sloc-tf')||'24';}catch(e){storedTf='24';}tfSel.value=storedTf;tfSel.addEventListener('change',function(){window.applyTf(this.value);});})();
28078 btn.addEventListener('click',function(e){e.stopPropagation();var r=btn.getBoundingClientRect();m.style.top=(r.bottom+6)+'px';m.style.right=(window.innerWidth-r.right)+'px';m.classList.toggle('open');});
28079 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
28080 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
28081 }
28082 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
28083 }());
28084 </script>
28085 <script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';if(lbl&&lbl.textContent==='Server')lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} \u2014 Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
28086</body>
28087</html>
28088"##,
28089 ext = "html"
28090)]
28091struct HistoryTemplate {
28092 version: &'static str,
28093 entries: Vec<HistoryEntryRow>,
28094 total_scans: usize,
28095 linked_count: usize,
28096 browse_error: Option<String>,
28097 watched_dirs: Vec<String>,
28098 csp_nonce: String,
28099 server_mode: bool,
28100}
28101
28102#[derive(Template)]
28105#[template(
28106 source = r##"
28107<!doctype html>
28108<html lang="en">
28109<head>
28110 <meta charset="utf-8">
28111 <meta name="viewport" content="width=device-width, initial-scale=1">
28112 <title>OxideSLOC | Compare Scans</title>
28113 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
28114 <style nonce="{{ csp_nonce }}">
28115 :root {
28116 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
28117 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
28118 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
28119 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
28120 --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
28121 }
28122 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
28123 *{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);} body{display:flex;flex-direction:column;}
28124 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
28125 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
28126 .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);}
28127 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
28128 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;} .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));}
28129 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
28130 .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;white-space:nowrap;}
28131 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
28132 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
28133 @media (max-width: 1150px) { .nav-right { gap: 4px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 8px; font-size: 11px; min-height: 34px; } .brand-subtitle { display: none; } .server-online-pill { width: 34px; padding: 0; justify-content: center; font-size: 0; gap: 0; min-height: 34px; } }
28134 .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;}
28135 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
28136 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
28137 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
28138 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
28139 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
28140 .settings-modal{position:fixed;z-index:9999;background:var(--surface-2);border:1px solid var(--line-strong);border-radius:14px;box-shadow:0 12px 36px rgba(0,0,0,0.22);min-width:260px;max-width:320px;opacity:0;pointer-events:none;transform:translateY(-8px) scale(0.97);transition:opacity 0.18s ease,transform 0.18s ease;overflow:hidden;}
28141 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
28142 .settings-modal-header{display:flex;align-items:center;justify-content:space-between;padding:14px 16px 10px;border-bottom:1px solid var(--line);font-size:13px;font-weight:800;color:var(--text);}
28143 .settings-close{background:none;border:none;cursor:pointer;width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--muted);border-radius:6px;padding:0;}
28144 .settings-close:hover{color:var(--text);background:var(--surface-2);}
28145 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
28146 .settings-modal-body{padding:14px 16px 16px;}
28147 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
28148 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
28149 .scheme-swatch{display:flex;flex-direction:column;align-items:center;gap:5px;background:none;border:1.5px solid var(--line);border-radius:10px;cursor:pointer;padding:7px 4px 6px;transition:border-color 0.15s ease,transform 0.12s ease;}
28150 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
28151 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
28152 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
28153 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
28154 .tz-select{width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2);color:var(--text);font-size:12px;font-weight:600;cursor:pointer;outline:none;box-sizing:border-box;}
28155 .tz-select:focus{border-color:var(--oxide);}
28156 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
28157 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
28158 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
28159 .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
28160 .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
28161 .panel-meta{font-size:13px;color:var(--muted);margin:0;}
28162 .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
28163 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
28164 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
28165 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
28166 .per-page-label{font-size:13px;color:var(--muted);}
28167 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;}
28168 .filter-input{min-width:180px;cursor:text;}
28169 .table-wrap{width:100%;overflow-x:auto;}
28170 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
28171 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;}
28172 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
28173 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
28174 #compare-table th:nth-child(1),#compare-table td:nth-child(1){min-width:52px;width:52px;padding-left:10px;padding-right:10px;box-sizing:border-box;text-align:center;}
28175 #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
28176 #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
28177 #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
28178 #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
28179 #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
28180 #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
28181 #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
28182 #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
28183 #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
28184 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
28185 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
28186 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
28187 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
28188 tr:last-child td{border-bottom:none;}
28189 tr.selected td{background:var(--sel-bg);}
28190 tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
28191 tr:hover:not(.selected):not(.row-locked) td{background:var(--surface-2);}
28192 tr{cursor:pointer;}
28193 tr.row-locked{opacity:.35;cursor:not-allowed;}
28194 tr.row-locked td{pointer-events:none;}
28195 .compare-all-bar{display:flex;flex-wrap:wrap;gap:8px;padding:10px 14px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;margin:10px 0 14px;align-items:center;}
28196 .compare-all-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);flex-shrink:0;}
28197 .compare-all-btn{display:inline-flex;align-items:center;gap:6px;padding:5px 12px;border-radius:7px;border:1px solid var(--accent-2);background:rgba(111,155,255,0.08);color:var(--accent-2);font-size:12px;font-weight:700;cursor:pointer;transition:background .12s;}
28198 .compare-all-btn:hover{background:rgba(111,155,255,0.18);}
28199 body.dark-theme .compare-all-btn{background:rgba(111,155,255,0.12);color:var(--accent);border-color:var(--accent);}
28200 body.dark-theme .compare-all-btn:hover{background:rgba(111,155,255,0.22);}
28201 .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);}
28202 .git-chip{font-family:ui-monospace,monospace;font-size:11px;font-weight:700;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);}
28203 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
28204 .metric-num{font-weight:700;color:var(--text);}
28205 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
28206 .commit-tip{position:fixed;z-index:9999;display:none;background:var(--text);color:var(--bg);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;font-weight:600;letter-spacing:.02em;padding:7px 11px;border-radius:8px;box-shadow:0 6px 20px rgba(0,0,0,0.28);pointer-events:none;white-space:nowrap;}
28207 .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;}
28208 tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
28209 .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;}
28210 .btn:hover{background:var(--line);}
28211 .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
28212 .btn.primary:hover{opacity:.9;}
28213 .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
28214 .watched-bar{display:flex;align-items:center;gap:10px;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:8px 12px;flex-wrap:wrap;margin-bottom:14px;position:relative;z-index:1;}
28215 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
28216 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
28217 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
28218 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
28219 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
28220 .watched-chip{display:inline-flex;align-items:center;gap:4px;background:var(--surface-2);border:1px solid var(--line);border-radius:6px;padding:3px 6px 3px 8px;font-size:11px;max-width:300px;}
28221 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
28222 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
28223 .watched-chip-rm:hover{color:var(--oxide);}
28224 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
28225 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
28226 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
28227 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
28228 .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
28229 .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;}
28230 .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;}
28231 .btn-back:hover{background:var(--line);}
28232 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
28233 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
28234 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
28235 .pagination-info{font-size:13px;color:var(--muted);}
28236 .pagination-btns{display:flex;gap:6px;}
28237 .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;}
28238 .pg-btn:hover:not(:disabled){background:var(--line);}
28239 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28240 .pg-btn:disabled{opacity:.35;cursor:default;}
28241 .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
28242 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
28243 .site-footer a{color:var(--muted);}
28244 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
28245 .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;}
28246 .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;}
28247 .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;}
28248 @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));}}
28249 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
28250 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
28251 .stat-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:14px 16px;position:relative;cursor:default;transition:transform .27s cubic-bezier(.16,1,.3,1),box-shadow .27s cubic-bezier(.16,1,.3,1);}
28252 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
28253 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
28254 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
28255 .stat-chip-tip{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%) translateY(-7px);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 .25s cubic-bezier(.16,1,.3,1), transform .25s cubic-bezier(.16,1,.3,1);z-index:200;box-shadow:0 4px 14px rgba(0,0,0,0.2);}
28256 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
28257 .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
28258 .stat-chip-exact{position:absolute;bottom:6px;right:10px;font-size:12px;font-weight:600;color:var(--muted);font-variant-numeric:tabular-nums;line-height:1;}
28259 .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;}
28260 .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%;}
28261 body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
28262 .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;}
28263 body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
28264 #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
28265 .hidden{display:none!important;}
28266 .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;display:inline-block;width:auto;max-width:100%;}
28267 @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
28268 body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
28269 .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;}
28270 .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
28271 .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
28272 .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;}
28273 .scope-option:hover{background:var(--line);}
28274 .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
28275 body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
28276 .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;}
28277 .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
28278 .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
28279 .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
28280 .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;white-space:nowrap;text-decoration:none;}.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;}
28281 </style>
28282</head>
28283<body>
28284 <div class="background-watermarks" aria-hidden="true">
28285 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28286 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28287 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28288 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28289 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28290 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28291 </div>
28292 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
28293 <div class="top-nav">
28294 <div class="top-nav-inner">
28295 <a class="brand" href="/">
28296 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
28297 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
28298 </a>
28299 <div class="nav-right">
28300 <a class="nav-pill" href="/">Home</a>
28301 <div class="nav-dropdown">
28302 <a href="/view-reports" class="nav-dropdown-btn">View Reports <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></a>
28303 <div class="nav-dropdown-menu">
28304 <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
28305 </div>
28306 </div>
28307 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
28308 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
28309 <div class="nav-dropdown">
28310 <a href="/git-browser" class="nav-dropdown-btn">Git Browser <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></a>
28311 <div class="nav-dropdown-menu">
28312 <a href="/integrations"><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>Integrations</a>
28313 </div>
28314 </div>
28315 <div class="server-status-wrap" id="server-status-wrap">
28316 <div class="nav-pill server-online-pill" id="server-status-pill">
28317 <span class="status-dot" id="status-dot"></span>
28318 <span id="server-status-label">Server</span>
28319 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
28320 </div>
28321 <div class="server-status-tip">
28322 OxideSLOC is running — accessible on your network.
28323 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
28324 </div>
28325 </div>
28326 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
28327 <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
28328 </button>
28329 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
28330 <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>
28331 <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>
28332 </button>
28333 </div>
28334 </div>
28335 </div>
28336
28337 <div class="page">
28338 <div class="watched-bar">
28339 <div class="watched-bar-left">
28340 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="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>
28341 <span class="watched-label">Watched Folders</span>
28342 <div class="watched-chips">
28343 {% if server_mode %}
28344 <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
28345 {% else %}
28346 {% for dir in watched_dirs %}
28347 <span class="watched-chip">
28348 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
28349 <form method="POST" action="/watched-dirs/remove" style="display:contents">
28350 <input type="hidden" name="folder_path" value="{{ dir }}">
28351 <input type="hidden" name="redirect_to" value="/compare-scans">
28352 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
28353 </form>
28354 </span>
28355 {% endfor %}
28356 {% if watched_dirs.is_empty() %}
28357 <span class="watched-none">No folders watched — click Choose to add one</span>
28358 {% endif %}
28359 {% endif %}
28360 </div>
28361 </div>
28362 {% if !server_mode %}
28363 <div class="watched-bar-right">
28364 <button type="button" class="btn" id="add-watched-btn">
28365 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
28366 Choose
28367 </button>
28368 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
28369 <input type="hidden" name="redirect_to" value="/compare-scans">
28370 <button type="submit" class="btn">↻ Refresh</button>
28371 </form>
28372 </div>
28373 {% endif %}
28374 </div>
28375 <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
28376 <div class="scan-overlay-card">
28377 <div class="scan-spinner"></div>
28378 <div class="scan-overlay-text">Scanning folder…</div>
28379 <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
28380 </div>
28381 </div>
28382 <style>
28383 .scan-overlay{position:fixed;inset:0;z-index:12000;display:none;align-items:center;justify-content:center;background:rgba(20,12,8,0.5);backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px);}
28384 .scan-overlay.active{display:flex;}
28385 .scan-overlay-card{background:var(--surface);border:1px solid var(--line-strong);border-radius:16px;padding:26px 38px;display:flex;flex-direction:column;align-items:center;gap:12px;box-shadow:0 24px 60px rgba(0,0,0,0.35);max-width:340px;text-align:center;}
28386 .scan-spinner{width:42px;height:42px;border-radius:50%;border:4px solid var(--line);border-top-color:var(--oxide);animation:scanSpin 0.8s linear infinite;}
28387 @keyframes scanSpin{to{transform:rotate(360deg);}}
28388 .scan-overlay-text{font-size:15px;font-weight:800;color:var(--text);}
28389 .scan-overlay-sub{font-size:12px;color:var(--muted);line-height:1.5;}
28390 </style>
28391 {% if total_scans > 0 %}
28392 <div class="summary-strip">
28393 <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>
28394 <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>
28395 <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>
28396 <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>
28397 </div>
28398 {% endif %}
28399 <section class="panel">
28400 <div class="panel-header">
28401 <div>
28402 <h1>Compare Scans</h1>
28403 <p class="panel-meta">{{ total_scans }} scan record(s) available. Select two or more scans from the same project, then press Compare.</p>
28404 </div>
28405 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
28406 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
28407 <button class="btn primary" id="compare-btn" disabled>
28408 <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>
28409 Compare <span class="sel-count" id="sel-count">0</span> Selected
28410 </button>
28411 </div>
28412 </div>
28413 </div>
28414
28415 {% if entries.is_empty() %}
28416 <div class="empty-state">
28417 <strong>No scans yet</strong>
28418 Run your first analysis from the <a href="/scan">scan page</a>, or click <strong>Choose</strong> above to watch a folder containing saved reports.
28419 </div>
28420 {% else %}
28421 <div class="filter-row">
28422 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name…">
28423 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
28424 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
28425 </div>
28426 <div class="scope-panel hidden" id="scope-panel">
28427 <div class="scope-panel-label">
28428 <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>
28429 Compare scope — choose what to include
28430 </div>
28431 <div class="scope-options" id="scope-options"></div>
28432 </div>
28433 {% if total_scans > 0 %}
28434 <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
28435 <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
28436 <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>
28437 Select rows from the <strong>same project</strong>, then press <strong>Compare</strong> — or use <strong>Compare All</strong> for a full project history.
28438 </div>
28439 </div>
28440 {% endif %}
28441 <div id="compare-all-bar" class="compare-all-bar" style="display:none">
28442 <span class="compare-all-label">
28443 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline></svg>
28444 Quick Compare All
28445 </span>
28446 </div>
28447 <div class="table-wrap">
28448 <table id="compare-table">
28449 <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
28450 <thead>
28451 <tr id="compare-thead">
28452 <th><div class="col-resize-handle"></div></th>
28453 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28454 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28455 <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
28456 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28457 <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>
28458 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28459 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28460 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28461 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28462 <th>Submodules<div class="col-resize-handle"></div></th>
28463 </tr>
28464 </thead>
28465 <tbody id="compare-tbody">
28466 {% for entry in entries %}
28467 <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
28468 data-timestamp="{{ entry.timestamp }}" data-sort-ts="{{ entry.timestamp_utc_ms }}"
28469 data-project="{{ entry.project_label }}"
28470 data-files="{{ entry.files_analyzed }}"
28471 data-code="{{ entry.code_lines }}"
28472 data-comments="{{ entry.comment_lines }}"
28473 data-blank="{{ entry.blank_lines }}"
28474 data-branch="{{ entry.git_branch }}"
28475 data-commit="{{ entry.git_commit }}"
28476 data-submodules="{{ entry.submodule_names_csv }}">
28477 <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
28478 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
28479 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
28480 <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
28481 <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
28482 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
28483 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
28484 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
28485 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
28486 <td>{% if !entry.git_commit.is_empty() %}<span class="git-chip git-commit-chip" style="cursor:help;" data-full-commit="{{ entry.git_commit_long }}">{{ entry.git_commit }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
28487 <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>
28488 </tr>
28489 {% endfor %}
28490 </tbody>
28491 </table>
28492 </div>
28493 <div class="pagination">
28494 <span class="pagination-info" id="pagination-info"></span>
28495 <div class="pagination-btns" id="pagination-btns"></div>
28496 <div class="flex-row">
28497 <span class="per-page-label">Show</span>
28498 <select class="per-page" id="per-page-sel">
28499 <option value="10">10 per page</option>
28500 <option value="25" selected>25 per page</option>
28501 <option value="50">50 per page</option>
28502 <option value="100">100 per page</option>
28503 </select>
28504 <span class="per-page-label" id="page-range-label"></span>
28505 </div>
28506 </div>
28507 {% endif %}
28508 </section>
28509 </div>
28510
28511 <footer class="site-footer">
28512 local code analysis - metrics, history and reports
28513 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
28514 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
28515 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
28516 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
28517 · <a href="/api-docs" rel="noopener">REST API</a>
28518 </footer>
28519
28520 <script nonce="{{ csp_nonce }}">
28521 (function () {
28522 // ── Theme ──────────────────────────────────────────────────────────────
28523 var storageKey = 'oxide-sloc-theme';
28524 var body = document.body;
28525 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
28526 var toggle = document.getElementById('theme-toggle');
28527 if (toggle) toggle.addEventListener('click', function () {
28528 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
28529 body.classList.toggle('dark-theme', next === 'dark');
28530 try { localStorage.setItem(storageKey, next); } catch(e) {}
28531 });
28532
28533 // ── State ─────────────────────────────────────────────────────────────
28534 var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
28535 var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
28536 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
28537 window._allCompareRows = allRows;
28538
28539 // ── Stat chips ────────────────────────────────────────────────────────
28540 (function() {
28541 var projects = {}, latestTs = '', latestRow = null;
28542 allRows.forEach(function(r) {
28543 var p = r.dataset.project || ''; if (p) projects[p] = true;
28544 var ts = r.dataset.timestamp || '';
28545 if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
28546 });
28547 function slocFmt(n){var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}
28548 function setChipVal(id,n){var el=document.getElementById(id);if(!el)return;var compact=slocFmt(n),full=Number(n).toLocaleString();el.innerHTML=compact+(compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'');}
28549 var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
28550 if (latestRow) {
28551 setChipVal('agg-code', latestRow.dataset.code);
28552 setChipVal('agg-files', latestRow.dataset.files);
28553 }
28554 Array.prototype.forEach.call(document.querySelectorAll('#compare-tbody .metric-num'), function(el) { var n = Number(el.textContent); if (!isNaN(n) && el.textContent.trim() !== '') el.textContent = n.toLocaleString(); });
28555 })();
28556
28557 // ── Branch filter population ──────────────────────────────────────────
28558 (function() {
28559 var branches = {};
28560 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
28561 var sel = document.getElementById('branch-filter');
28562 if (sel) Object.keys(branches).sort().forEach(function(b) {
28563 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
28564 });
28565 })();
28566
28567 // ── Filter ────────────────────────────────────────────────────────────
28568 function getFilteredRows() {
28569 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
28570 var branch = ((document.getElementById('branch-filter') || {}).value || '');
28571 return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
28572 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
28573 if (branch && (r.dataset.branch || '') !== branch) return false;
28574 return true;
28575 });
28576 }
28577
28578 // ── Pagination ────────────────────────────────────────────────────────
28579 function renderPage() {
28580 var filtered = getFilteredRows();
28581 var total = filtered.length;
28582 var totalPages = Math.max(1, Math.ceil(total / perPage));
28583 currentPage = Math.min(currentPage, totalPages);
28584 var start = (currentPage - 1) * perPage;
28585 var end = Math.min(start + perPage, total);
28586 var shown = {};
28587 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
28588 Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
28589 r.style.display = shown[r.dataset.run] ? '' : 'none';
28590 });
28591 var rl = document.getElementById('page-range-label');
28592 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
28593 var info = document.getElementById('pagination-info');
28594 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
28595 var btns = document.getElementById('pagination-btns');
28596 if (!btns) return;
28597 btns.innerHTML = '';
28598 function makeBtn(lbl, pg, active, disabled) {
28599 var b = document.createElement('button');
28600 b.className = 'pg-btn' + (active ? ' active' : '');
28601 b.textContent = lbl; b.disabled = disabled;
28602 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
28603 return b;
28604 }
28605 btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
28606 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
28607 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
28608 btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
28609 }
28610
28611 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
28612 window.applyFilters = function() { currentPage = 1; renderPage(); };
28613
28614 // ── Sorting ───────────────────────────────────────────────────────────
28615 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
28616 function doSort(col, type, order) {
28617 var tbody = document.getElementById('compare-tbody');
28618 if (!tbody) return;
28619 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
28620 rows.sort(function(a, b) {
28621 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
28622 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
28623 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
28624 return va < vb ? 1 : va > vb ? -1 : 0;
28625 });
28626 rows.forEach(function(r) { tbody.appendChild(r); });
28627 currentPage = 1; renderPage();
28628 }
28629 sortHeaders.forEach(function(th) {
28630 th.addEventListener('click', function(e) {
28631 if (e.target.classList.contains('col-resize-handle')) return;
28632 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
28633 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
28634 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
28635 th.classList.add('sort-' + sortOrder);
28636 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
28637 doSort(col, type, sortOrder);
28638 });
28639 });
28640
28641 // Apply default sort (timestamp desc) on initial load
28642 (function() {
28643 var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
28644 if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '\u2193'; doSort('timestamp', 'str', 'desc'); }
28645 })();
28646
28647 // ── Column resize ─────────────────────────────────────────────────────
28648 (function() {
28649 var table = document.getElementById('compare-table');
28650 if (!table) return;
28651 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
28652 var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
28653 ths.forEach(function(th, i) {
28654 var handle = th.querySelector('.col-resize-handle');
28655 if (!handle || !cols[i]) return;
28656 var startX, startW;
28657 handle.addEventListener('mousedown', function(e) {
28658 e.stopPropagation(); e.preventDefault();
28659 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
28660 handle.classList.add('dragging');
28661 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
28662 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
28663 document.addEventListener('mousemove', onMove);
28664 document.addEventListener('mouseup', onUp);
28665 });
28666 });
28667 })();
28668
28669 // ── Full-commit hover tooltip ─────────────────────────────────────────
28670 // The commit chips live inside an overflow:auto table wrapper, which would
28671 // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
28672 // (escaping the scroll container) and follow the cursor. Event delegation
28673 // keeps it working after pagination/sorting re-renders the rows.
28674 (function() {
28675 var tip = document.createElement('div');
28676 tip.className = 'commit-tip';
28677 tip.setAttribute('role', 'tooltip');
28678 document.body.appendChild(tip);
28679 var shown = false;
28680 function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
28681 function place(e) {
28682 var pad = 14, r = tip.getBoundingClientRect();
28683 var x = e.clientX + pad, y = e.clientY + pad;
28684 if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
28685 if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
28686 tip.style.left = x + 'px'; tip.style.top = y + 'px';
28687 }
28688 function hide() { tip.style.display = 'none'; shown = false; }
28689 document.addEventListener('mouseover', function(e) {
28690 var chip = chipFrom(e.target);
28691 if (!chip) return;
28692 var full = chip.getAttribute('data-full-commit');
28693 if (!full) return;
28694 tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
28695 });
28696 document.addEventListener('mousemove', function(e) {
28697 if (!shown) return;
28698 if (chipFrom(e.target)) place(e); else hide();
28699 });
28700 document.addEventListener('mouseout', function(e) {
28701 if (chipFrom(e.target)) hide();
28702 });
28703 })();
28704
28705 // ── Reset view ────────────────────────────────────────────────────────
28706 window.resetView = function() {
28707 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
28708 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
28709 sortCol = null; sortOrder = 'asc';
28710 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
28711 var tbody = document.getElementById('compare-tbody');
28712 if (tbody) {
28713 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
28714 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
28715 rows.forEach(function(r) { tbody.appendChild(r); });
28716 }
28717 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
28718 var table = document.getElementById('compare-table');
28719 currentPage = 1; renderPage();
28720 currentPage = 1; renderPage();
28721 };
28722
28723 renderPage();
28724 buildCompareAllBar();
28725
28726 // ── Row selection state ───────────────────────────────────────────────
28727 var selected = [];
28728 var lockedProject = null; // project label of first selected scan
28729
28730 function updateCompareBtn() {
28731 var btn = document.getElementById('compare-btn');
28732 var cnt = document.getElementById('sel-count');
28733 if (!btn) return;
28734 btn.disabled = selected.length < 2;
28735 if (cnt) cnt.textContent = selected.length;
28736 }
28737
28738 function applyProjectLock() {
28739 var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
28740 allRows.forEach(function(r) {
28741 if (lockedProject === null) {
28742 r.classList.remove('row-locked');
28743 } else {
28744 var proj = r.dataset.project || '';
28745 if (proj !== lockedProject) {
28746 r.classList.add('row-locked');
28747 } else {
28748 r.classList.remove('row-locked');
28749 }
28750 }
28751 });
28752 }
28753
28754 function toggleRow(row) {
28755 if (row.classList.contains('row-locked')) return;
28756 var vid = row.dataset.vid || row.dataset.run;
28757 var idx = selected.indexOf(vid);
28758 if (idx >= 0) {
28759 selected.splice(idx, 1);
28760 row.classList.remove('selected');
28761 var b = document.getElementById('badge-' + vid);
28762 if (b) b.textContent = '';
28763 // Release project lock if nothing selected
28764 if (selected.length === 0) lockedProject = null;
28765 } else {
28766 // Set project lock on first selection
28767 if (selected.length === 0) lockedProject = row.dataset.project || null;
28768 selected.push(vid);
28769 row.classList.add('selected');
28770 }
28771 selected.forEach(function(v, i) {
28772 var b = document.getElementById('badge-' + v);
28773 if (b) b.textContent = i + 1;
28774 });
28775 applyProjectLock();
28776 updateCompareBtn();
28777 buildScopePanel();
28778 }
28779
28780 // ── Compare-All bar ───────────────────────────────────────────────────
28781 function buildCompareAllBar() {
28782 var bar = document.getElementById('compare-all-bar');
28783 if (!bar) return;
28784 // Group all rows by project label.
28785 var groups = {};
28786 var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
28787 // Use all rows from the source data (not just visible).
28788 var allRowsAll = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
28789 // We need ALL rows across all pages, not just the rendered ones.
28790 // Use the underlying allRows array that the pagination JS also uses.
28791 var sourceRows = window._allCompareRows || allRowsAll;
28792 sourceRows.forEach(function(r) {
28793 var proj = r.dataset.project || '';
28794 var vid = r.dataset.vid || r.dataset.run || '';
28795 if (!proj || !vid) return;
28796 if (!groups[proj]) groups[proj] = { ids: [], ts: [] };
28797 groups[proj].ids.push(vid);
28798 groups[proj].ts.push(parseInt(r.dataset.sortTs || '0', 10) || 0);
28799 });
28800 // Build buttons for each project with >= 2 scans.
28801 var keys = Object.keys(groups).filter(function(k) { return groups[k].ids.length >= 2; });
28802 if (!keys.length) { bar.style.display = 'none'; return; }
28803 bar.style.display = 'flex';
28804 // Remove old buttons (keep label).
28805 var oldBtns = bar.querySelectorAll('.compare-all-btn');
28806 oldBtns.forEach(function(b) { b.remove(); });
28807 keys.sort();
28808 keys.forEach(function(proj) {
28809 var g = groups[proj];
28810 var btn = document.createElement('button');
28811 btn.className = 'compare-all-btn';
28812 btn.type = 'button';
28813 btn.textContent = proj + ' (' + g.ids.length + ' scans)';
28814 btn.title = 'Compare all ' + g.ids.length + ' scans of ' + proj;
28815 btn.addEventListener('click', function() {
28816 // Sort ids by timestamp (ascending).
28817 var pairs = g.ids.map(function(id, i) { return { id: id, ts: g.ts[i] }; });
28818 pairs.sort(function(a, b) { return a.ts - b.ts; });
28819 var sorted = pairs.map(function(p) { return p.id; });
28820 if (sorted.length === 2) {
28821 window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
28822 } else {
28823 window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
28824 }
28825 });
28826 bar.appendChild(btn);
28827 });
28828 }
28829
28830 // ── Scope panel ───────────────────────────────────────────────────────
28831 var selectedScope = 'all';
28832
28833 function buildScopePanel() {
28834 var panel = document.getElementById('scope-panel');
28835 var opts = document.getElementById('scope-options');
28836 if (!panel || !opts) return;
28837 if (selected.length < 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
28838
28839 // Collect union of submodules from all selected rows.
28840 var allSubs = {};
28841 selected.forEach(function(vid) {
28842 var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
28843 if (!row) return;
28844 (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
28845 });
28846 var subList = Object.keys(allSubs).sort();
28847 if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
28848
28849 panel.classList.remove('hidden');
28850 opts.innerHTML = '';
28851
28852 function makeOption(value, label, title) {
28853 var div = document.createElement('div');
28854 div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
28855 div.dataset.scopeValue = value;
28856 if (title) div.title = title;
28857 var radio = document.createElement('span');
28858 radio.className = 'scope-option-radio';
28859 var lbl = document.createElement('span');
28860 lbl.textContent = label;
28861 div.appendChild(radio);
28862 div.appendChild(lbl);
28863 div.addEventListener('click', function() {
28864 selectedScope = value;
28865 opts.querySelectorAll('.scope-option').forEach(function(o) {
28866 o.classList.toggle('selected', o.dataset.scopeValue === value);
28867 });
28868 });
28869 return div;
28870 }
28871
28872 opts.appendChild(makeOption('all', 'Full scan', 'All files \u2014 super-repo and submodules combined'));
28873 var sep = document.createElement('span');
28874 sep.className = 'scope-option-sep';
28875 opts.appendChild(sep);
28876 opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
28877 subList.forEach(function(s) {
28878 opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule \u201c' + s + '\u201d'));
28879 });
28880 }
28881
28882 function doCompare() {
28883 if (selected.length < 2) return;
28884 if (selected.length === 2) {
28885 // Two-scan delta (existing flow with scope support).
28886 var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
28887 if (selectedScope === 'super') url += '&scope=super';
28888 else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
28889 window.location.href = url;
28890 } else {
28891 // Multi-scan timeline (N >= 3) — pass scope params too.
28892 var url = '/multi-compare?runs=' + selected.map(encodeURIComponent).join(',');
28893 if (selectedScope === 'super') url += '&scope=super';
28894 else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
28895 window.location.href = url;
28896 }
28897 }
28898
28899 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
28900 var cbtn = document.getElementById('compare-btn');
28901 if (cbtn) cbtn.addEventListener('click', doCompare);
28902 var pfEl = document.getElementById('project-filter');
28903 if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
28904 var bfEl = document.getElementById('branch-filter');
28905 if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
28906 var rvBtn = document.getElementById('reset-view-btn');
28907 if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
28908 var ppSel = document.getElementById('per-page-sel');
28909 if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
28910
28911 var cmpTbody = document.getElementById('compare-tbody');
28912 if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
28913 var row = e.target.closest('.compare-row');
28914 if (row) toggleRow(row);
28915 });
28916
28917 (function randomizeWatermarks() {
28918 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
28919 if (!wms.length) return;
28920 var placed = [];
28921 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;}
28922 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];}
28923 var half=Math.floor(wms.length/2);
28924 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;});
28925 })();
28926
28927 (function spawnCodeParticles() {
28928 var container = document.getElementById('code-particles');
28929 if (!container) return;
28930 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'];
28931 for (var i = 0; i < 38; i++) {
28932 (function(idx) {
28933 var el = document.createElement('span');
28934 el.className = 'code-particle';
28935 el.textContent = snippets[idx % snippets.length];
28936 var left = Math.random() * 94 + 2;
28937 var top = Math.random() * 88 + 6;
28938 var dur = (Math.random() * 10 + 9).toFixed(1);
28939 var delay = (Math.random() * 18).toFixed(1);
28940 var rot = (Math.random() * 26 - 13).toFixed(1);
28941 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
28942 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';
28943 container.appendChild(el);
28944 })(i);
28945 }
28946 })();
28947
28948 // ── Watched folder picker ─────────────────────────────────────────────
28949 (function(){
28950 window.__scanOverlay=function(msg){var o=document.getElementById('scan-overlay');if(!o)return;if(o.parentNode!==document.body)document.body.appendChild(o);var t=o.querySelector('.scan-overlay-text');if(t&&msg)t.textContent=msg;o.classList.add('active');};
28951 document.addEventListener('submit',function(e){var f=e.target;if(!f||!f.getAttribute)return;var a=f.getAttribute('action')||'';if(a.indexOf('/watched-dirs/remove')!==-1){window.__scanOverlay('Updating watched folders');}else if(a.indexOf('/watched-dirs/')!==-1){window.__scanOverlay();}},true);
28952 })();
28953 (function() {
28954 var btn = document.getElementById('add-watched-btn');
28955 if (!btn) return;
28956 btn.addEventListener('click', function() {
28957 fetch('/pick-directory?kind=reports')
28958 .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
28959 .then(function(data) {
28960 if (!data.cancelled && data.selected_path) {
28961 var form = document.createElement('form');
28962 form.method = 'POST';
28963 form.action = '/watched-dirs/add';
28964 var ri = document.createElement('input');
28965 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
28966 var fi = document.createElement('input');
28967 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
28968 form.appendChild(ri); form.appendChild(fi);
28969 document.body.appendChild(form);
28970 if (window.__scanOverlay) window.__scanOverlay();
28971 form.submit();
28972 }
28973 })
28974 .catch(function(e) { alert('Could not open folder picker: ' + e); });
28975 });
28976 })();
28977
28978 // ── Submodule chip truncation ─────────────────────────────────────────
28979 document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
28980 var chips = cell.querySelectorAll('.submod-chip');
28981 var MAX = 4;
28982 if (chips.length <= MAX) return;
28983 for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
28984 var badge = document.createElement('span');
28985 badge.className = 'submod-overflow-badge';
28986 badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
28987 badge.textContent = '+' + (chips.length - MAX) + ' more';
28988 cell.appendChild(badge);
28989 cell.style.maxHeight = 'none';
28990 });
28991 })();
28992 </script>
28993 <script nonce="{{ csp_nonce }}">
28994 (function(){
28995 var S=[{n:'Classic',a:'#b85d33',b:'#7a371b'},{n:'Navy',a:'#283790',b:'#1e1e24'},{n:'Ember',a:'#ce5d3d',b:'#1e1e24'},{n:'Ocean',a:'#1f439b',b:'#1e1e24'},{n:'Royal',a:'#003184',b:'#1e1e24'}];
28996 function ap(s){document.documentElement.style.setProperty('--nav',s.a);document.documentElement.style.setProperty('--nav-2',s.b);try{localStorage.setItem('sloc-ns',JSON.stringify(s));}catch(e){}document.querySelectorAll('.scheme-swatch').forEach(function(x){x.classList.toggle('active',x.dataset.n===s.n);});}
28997 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
28998 function init(){
28999 var btn=document.getElementById('settings-btn');if(!btn)return;
29000 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
29001 m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
29002 document.body.appendChild(m);
29003 var g=document.getElementById('scheme-grid');
29004 if(g)S.forEach(function(s){var el=document.createElement('button');el.type='button';el.className='scheme-swatch';el.dataset.n=s.n;el.title=s.n;var p=document.createElement('div');p.className='scheme-preview';p.style.background='linear-gradient(135deg,'+s.a+','+s.b+')';var l=document.createElement('span');l.className='scheme-label';l.textContent=s.n;el.appendChild(p);el.appendChild(l);try{var c=JSON.parse(localStorage.getItem('sloc-ns'));if(c&&c.n===s.n)el.classList.add('active');}catch(e){}el.addEventListener('click',function(){ap(s);});g.appendChild(el);});
29005 var cl=document.getElementById('settings-close');
29006 window.tzAbbr=function(z){return{'America/Los_Angeles':'PT','America/Denver':'MT','America/Chicago':'CT','America/New_York':'ET','America/Anchorage':'AT','Pacific/Honolulu':'HT'}[z]||'PT';};window.tzCity=function(z){return{'America/Los_Angeles':'Los Angeles','America/Denver':'Denver','America/Chicago':'Chicago','America/New_York':'New York','America/Anchorage':'Anchorage','Pacific/Honolulu':'Honolulu'}[z]||'';};window.tzOffset=function(z){var r='';try{var p=new Intl.DateTimeFormat('en-US',{timeZone:z,timeZoneName:'longOffset'}).formatToParts(new Date());p.forEach(function(x){if(x.type==='timeZoneName')r=x.value.replace('GMT','UTC');});}catch(e){}return r;};window.tf24=function(){try{return localStorage.getItem('sloc-tf')!=='12';}catch(e){return true;}};window.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';var h24=window.tf24();try{var pts=new Intl.DateTimeFormat('en-US',{timeZone:tz,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:!h24}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});var t=v.hour+':'+v.minute;if(!h24&&v.dayPeriod)t+=' '+v.dayPeriod;return v.year+'-'+v.month+'-'+v.day+' '+t+' '+window.tzAbbr(tz);}catch(e){return'';}};window.enhanceTzOptions=function(sel){if(!sel)return;Array.prototype.forEach.call(sel.options,function(o){var base=o.textContent.split(' - ')[0];var city=window.tzCity(o.value);var off=window.tzOffset(o.value);o.textContent=base+(city?' - '+city:'')+(off?' - '+off:'');});};window.applyTz=function(tz){try{localStorage.setItem('sloc-tz',tz);}catch(e){}document.querySelectorAll('[data-utc-ms]').forEach(function(el){var ms=parseInt(el.getAttribute('data-utc-ms'),10);if(!isNaN(ms))el.textContent=window.fmtTz(ms,tz);});};window.applyTf=function(tf){try{localStorage.setItem('sloc-tf',tf);}catch(e){}var z;try{z=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){z='America/Los_Angeles';}window.applyTz(z);};var tzSel=document.getElementById('tz-select');window.enhanceTzOptions(tzSel);var storedTz;try{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){storedTz='America/Los_Angeles';}if(tzSel){tzSel.value=storedTz;tzSel.addEventListener('change',function(){window.applyTz(this.value);});}window.applyTz(storedTz);(function(){var tzp=document.getElementById('tz-select');if(!tzp||document.getElementById('tf-select')||!tzp.parentNode)return;var tw=document.createElement('div');tw.style.marginTop='10px';var tl=document.createElement('div');tl.className='settings-modal-label';tl.style.marginBottom='8px';tl.textContent='Time format';var tfSel=document.createElement('select');tfSel.className='tz-select';tfSel.id='tf-select';tfSel.innerHTML='<option value="24">24-hour (14:30)</option><option value="12">12-hour (2:30 PM)</option>';tw.appendChild(tl);tw.appendChild(tfSel);tzp.parentNode.appendChild(tw);var storedTf;try{storedTf=localStorage.getItem('sloc-tf')||'24';}catch(e){storedTf='24';}tfSel.value=storedTf;tfSel.addEventListener('change',function(){window.applyTf(this.value);});})();
29007 btn.addEventListener('click',function(e){e.stopPropagation();var r=btn.getBoundingClientRect();m.style.top=(r.bottom+6)+'px';m.style.right=(window.innerWidth-r.right)+'px';m.classList.toggle('open');});
29008 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
29009 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
29010 }
29011 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
29012 }());
29013 </script>
29014 <script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';
29015 if(location.protocol==='file:'){if(lbl)lbl.textContent='Offline';if(dot){dot.style.background='#888';dot.style.boxShadow='none';}if(pingEl)pingEl.textContent='';if(fm)fm.textContent='oxide-sloc v{{ version }} \u2014 Saved Report';var td=document.querySelector('.server-status-tip');if(td)td.textContent='Saved HTML report \u2014 server not connected.';return;}
29016 if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
29017</body>
29018</html>
29019"##,
29020 ext = "html"
29021)]
29022struct CompareSelectTemplate {
29023 version: &'static str,
29024 entries: Vec<HistoryEntryRow>,
29025 total_scans: usize,
29026 watched_dirs: Vec<String>,
29027 csp_nonce: String,
29028 server_mode: bool,
29029}
29030
29031#[derive(Template)]
29034#[template(
29035 source = r##"
29036<!doctype html>
29037<html lang="en">
29038<head>
29039 <meta charset="utf-8">
29040 <meta name="viewport" content="width=device-width, initial-scale=1">
29041 <title>OxideSLOC | Scan Delta</title>
29042 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
29043 <style nonce="{{ csp_nonce }}">
29044 :root {
29045 --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
29046 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
29047 --nav:#283790; --nav-2:#013e6b;
29048 --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
29049 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
29050 --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
29051 }
29052 body.dark-theme {
29053 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
29054 --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
29055 }
29056 *{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);} body{display:flex;flex-direction:column;}
29057 .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);}
29058 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;flex-wrap:nowrap;}
29059 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;} .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));}
29060 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
29061 .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;white-space:nowrap;}
29062 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
29063 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
29064 @media (max-width: 1150px) { .nav-right { gap: 4px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 8px; font-size: 11px; min-height: 34px; } .brand-subtitle { display: none; } .server-online-pill { width: 34px; padding: 0; justify-content: center; font-size: 0; gap: 0; min-height: 34px; } }
29065 .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;white-space:nowrap;text-decoration:none;}
29066 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
29067 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
29068 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
29069 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
29070 .settings-modal{position:fixed;z-index:9999;background:var(--surface-2);border:1px solid var(--line-strong);border-radius:14px;box-shadow:0 12px 36px rgba(0,0,0,0.22);min-width:260px;max-width:320px;opacity:0;pointer-events:none;transform:translateY(-8px) scale(0.97);transition:opacity 0.18s ease,transform 0.18s ease;overflow:hidden;}
29071 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
29072 .settings-modal-header{display:flex;align-items:center;justify-content:space-between;padding:14px 16px 10px;border-bottom:1px solid var(--line);font-size:13px;font-weight:800;color:var(--text);}
29073 .settings-close{background:none;border:none;cursor:pointer;width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:var(--muted);border-radius:6px;padding:0;}
29074 .settings-close:hover{color:var(--text);background:var(--surface-2);}
29075 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
29076 .settings-modal-body{padding:14px 16px 16px;}
29077 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
29078 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
29079 .scheme-swatch{display:flex;flex-direction:column;align-items:center;gap:5px;background:none;border:1.5px solid var(--line);border-radius:10px;cursor:pointer;padding:7px 4px 6px;transition:border-color 0.15s ease,transform 0.12s ease;}
29080 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
29081 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
29082 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
29083 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
29084 .tz-select{width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2);color:var(--text);font-size:12px;font-weight:600;cursor:pointer;outline:none;box-sizing:border-box;}
29085 .tz-select:focus{border-color:var(--oxide);}
29086 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
29087 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
29088 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
29089 .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;}
29090 .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
29091 .hero-body{display:block;}
29092 .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;}
29093 .btn-back:hover{background:var(--line);}
29094 h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
29095 h2{margin:0 0 14px;font-size:18px;font-weight:750;}
29096 .delta-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 4px;background:linear-gradient(90deg,#b85d33 0%,#d37a4c 40%,#6f9bff 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;}
29097 .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
29098 body.dark-theme .delta-title{background:linear-gradient(90deg,#f0a070 0%,#d37a4c 40%,#9bb8ff 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;}
29099 .muted{color:var(--muted);font-size:14px;}
29100 .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
29101 .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;}
29102 .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
29103 .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
29104 .vpill-arrow{font-size:20px;color:var(--muted);}
29105 .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
29106 .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
29107 .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;}
29108 .delta-card.delta-card-wide{padding:22px 24px;}
29109 .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
29110 body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
29111 .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
29112 .delta-card-from{font-size:15px;color:var(--muted);}
29113 .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
29114 .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
29115 .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
29116 .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%;}
29117 .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;}
29118 .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
29119 .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
29120 .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
29121 .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
29122 body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
29123 body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
29124 .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;}
29125 .meta-card-commit:hover{color:var(--oxide);}
29126 .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
29127 .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
29128 .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
29129 .meta-value{color:var(--text);font-size:13px;}
29130 .cmp-author-handle{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}
29131 .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.6;width:290px;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;}
29132 .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);}
29133 .delta-card:hover .dc-tip{display:block;}
29134 .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;}
29135 .export-btn:hover{background:var(--line);}
29136 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
29137 .panel-title{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}
29138 .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
29139 .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
29140 .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
29141 .delta-card-change.zero{color:var(--muted);background:transparent;}
29142 .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
29143 .delta-card-pct.pos{color:var(--pos);}
29144 .delta-card-pct.neg{color:var(--neg);}
29145 .delta-card-pct.zero{color:var(--muted);}
29146 .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
29147 .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;}
29148 .insight-card.insight-flag{border-color:var(--oxide);}
29149 .insight-card:hover .dc-tip{display:block;}
29150 .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
29151 .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
29152 .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
29153 .insight-label.flag{color:var(--oxide);}
29154 .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
29155 .insight-val.pos{color:var(--pos);}
29156 .insight-val.neg{color:var(--neg);}
29157 .insight-val.high{color:#c0392a;}
29158 .insight-val.med{color:#926000;}
29159 .insight-val.low{color:var(--pos);}
29160 body.dark-theme .insight-val.high{color:#ff6b6b;}
29161 body.dark-theme .insight-val.med{color:#f0c060;}
29162 .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
29163 .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
29164 .fc-row{display:flex;align-items:center;gap:8px;}
29165 .fc-count{font-weight:800;font-size:16px;min-width:28px;}
29166 .fc-label{color:var(--muted);}
29167 .fc-modified .fc-count{color:#926000;}
29168 .fc-added .fc-count{color:var(--pos);}
29169 .fc-removed .fc-count{color:var(--neg);}
29170 .fc-unchanged .fc-count{color:var(--muted);}
29171 body.dark-theme .fc-modified .fc-count{color:#f0c060;}
29172 .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
29173 .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
29174 .chip.modified{background:#fff2d8;color:#926000;}
29175 .chip.added{background:#e8f5ed;color:#1a8f47;}
29176 .chip.removed{background:#fdeaea;color:#b33b3b;}
29177 .chip.unchanged{background:var(--surface-2);color:var(--muted);}
29178 body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
29179 body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
29180 body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
29181 .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
29182 .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
29183 .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;}
29184 .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
29185 .tab-btn:hover:not(.active){background:var(--line);}
29186 .btn-reset{display:inline-flex;align-items:center;gap:5px;padding:5px 13px;border-radius:7px;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;transition:background .12s ease;white-space:nowrap;}
29187 .btn-reset:hover{background:var(--line);}
29188 .table-wrap{width:100%;overflow-x:auto;}
29189 table{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}
29190 th{text-align:left;font-size:10px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);padding:8px 10px;border-bottom:2px solid var(--line);white-space:nowrap;position:relative;user-select:none;background:var(--surface-2);}
29191 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
29192 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
29193 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
29194 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
29195 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
29196 td{padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:middle;white-space:nowrap;}
29197 tr:last-child td{border-bottom:none;}
29198 tr:hover td{background:var(--surface-2);}
29199 .col-num{text-align:right;font-variant-numeric:tabular-nums;}
29200 #delta-table th:nth-child(n+4),#delta-table td:nth-child(n+4){text-align:right;font-variant-numeric:tabular-nums;}
29201 #delta-table th:last-child,#delta-table td:last-child{padding-right:14px;}
29202 /* Fixed layout: column widths come from the colgroup, not from scanning every
29203 row. With auto layout a large file matrix forces the browser to re-measure
29204 all cells on each reflow, which freezes the page during sort/resize. */
29205 #delta-table{table-layout:fixed;}
29206 #delta-table col:nth-child(1){width:32%;}
29207 #delta-table col:nth-child(2){width:11%;}
29208 #delta-table col:nth-child(3){width:11%;}
29209 #delta-table col:nth-child(4){width:16%;}
29210 #delta-table col:nth-child(5){width:10%;}
29211 #delta-table col:nth-child(6){width:10%;}
29212 #delta-table col:nth-child(7){width:10%;}
29213 tr.row-added td{background:rgba(26,143,71,0.04);}
29214 tr.row-removed td{background:rgba(179,59,59,0.06);}
29215 tr.row-modified td{background:rgba(146,96,0,0.04);}
29216 tr.row-unchanged td{color:var(--muted);}
29217 tr.row-unchanged .status-badge{opacity:.65;}
29218 .file-path{font-family:ui-monospace,monospace;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:340px;display:inline-block;vertical-align:middle;}
29219 .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
29220 .status-badge.added{background:#e8f5ed;color:#1a8f47;}
29221 .status-badge.removed{background:#fdeaea;color:#b33b3b;}
29222 .status-badge.modified{background:#fff2d8;color:#926000;}
29223 .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
29224 body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
29225 body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
29226 body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
29227 .delta-val{font-weight:700;}
29228 .delta-val.pos{color:var(--pos);}
29229 .delta-val.neg{color:var(--neg);}
29230 .delta-val.zero{color:var(--muted);}
29231 .from-to{display:flex;align-items:center;gap:5px;white-space:nowrap;font-size:13px;}
29232 .from-to strong{color:var(--text);font-weight:700;}
29233 .from-to .ft-sep{color:var(--muted-2);font-size:11px;}
29234 .from-to .ft-absent{color:var(--muted);font-weight:600;}
29235 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
29236 .site-footer a{color:var(--muted);}
29237 body.pdf-mode .top-nav,body.pdf-mode .background-watermarks,body.pdf-mode #code-particles,body.pdf-mode .export-group,body.pdf-mode .btn-reset,body.pdf-mode .filter-tabs,body.pdf-mode .filter-tabs-row,body.pdf-mode .pagination,body.pdf-mode select.per-page,body.pdf-mode .settings-modal,body.pdf-mode .site-footer,body.pdf-mode .scope-bar,body.pdf-mode .submod-scope-bar{display:none!important;}
29238 body.pdf-mode{background:#fff!important;}
29239 body.pdf-mode .page{padding:4px 6px 4px!important;}
29240 @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
29241 @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
29242 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
29243 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
29244 .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;}
29245 .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;}
29246 .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;}
29247 @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));}}
29248 .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
29249 .path-link:hover{color:var(--oxide-2);}
29250 .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
29251 a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
29252 a.vpill-id:hover{color:var(--oxide);}
29253 .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
29254 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
29255 .pagination-info{font-size:13px;color:var(--muted);}
29256 .pagination-btns{display:flex;gap:6px;}
29257 .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;}
29258 .pg-btn:hover:not(:disabled){background:var(--line);}
29259 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29260 .pg-btn:disabled{opacity:.35;cursor:default;}
29261 .per-page-label{font-size:13px;color:var(--muted);}
29262 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;}
29263 .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29264 .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
29265 .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
29266 .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
29267 .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
29268 .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
29269 .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
29270 .tab-btn.tab-unchanged{color:var(--muted);}
29271 body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
29272 body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
29273 body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
29274 .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;white-space:nowrap;text-decoration:none;}.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;}
29275 .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;}
29276 .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
29277 .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;}
29278 .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
29279 .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;}
29280 .submod-scope-btn:hover{background:var(--line);}
29281 .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29282 .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
29283 .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
29284 @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
29285 .ic-card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
29286 body.dark-theme .ic-card{background:var(--surface-2);}
29287 .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
29288 .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}
29289 .ic-leg-item{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}
29290 .ic-leg-item:hover{background:rgba(211,122,76,0.08);}
29291 .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
29292 .ic-cb{cursor:pointer;transition:opacity .17s,filter .17s,transform .17s;transform-box:fill-box;transform-origin:center center;}.ic-cb:hover{filter:brightness(1.15) drop-shadow(0 2px 6px rgba(0,0,0,.18));transform:scale(1.05);}
29293 .ic-card-h2-row{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}
29294 .ic-card-h2-row .ic-card-h2{margin:0;}
29295 .ic-expand-btn{background:none;border:1px solid var(--line-strong);border-radius:6px;cursor:pointer;color:var(--muted);padding:4px 10px;font-size:12px;line-height:1;transition:background .13s,color .13s;flex-shrink:0;white-space:nowrap;margin-left:auto;}
29296 .ic-expand-btn:hover{background:var(--surface-2);color:var(--text);}
29297 .ic-svg-modal-ov{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.58);z-index:9998;align-items:center;justify-content:center;padding:24px;box-sizing:border-box;}
29298 .ic-svg-modal-ov.open{display:flex;}
29299 .ic-svg-modal{background:var(--surface);border:1px solid var(--line-strong);border-radius:14px;padding:22px 24px;max-width:1100px;width:100%;max-height:88vh;overflow-y:auto;position:relative;box-shadow:0 24px 80px rgba(0,0,0,0.3);}
29300 body.dark-theme .ic-svg-modal{background:var(--surface-2);}
29301 .ic-svg-modal-hdr{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;padding-bottom:12px;border-bottom:1px solid var(--line);}
29302 .ic-svg-modal-title{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}
29303 .ic-svg-modal-close{background:var(--surface-2);border:1px solid var(--line);border-radius:7px;padding:5px 11px;cursor:pointer;color:var(--text);font-size:12px;font-weight:700;}
29304 .ic-svg-modal-close:hover{background:var(--line);}
29305 .chart-metric-btn{padding:5px 13px;border-radius:7px;border:1px solid var(--line-strong);background:var(--surface-2);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;transition:background .12s;}
29306 .chart-metric-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29307 .chart-metric-btn:hover:not(.active){background:var(--line);}
29308 .chart-wrap{width:100%;overflow-x:auto;}
29309 #cmp-tl-svg{display:block;width:100%;}
29310 .git-chip{font-family:ui-monospace,monospace;font-size:11px;font-weight:700;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);}
29311 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
29312 #ic-tt{display:none;position:fixed;background:rgba(15,10,6,.95);color:rgba(255,255,255,0.92);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);max-width:240px;white-space:nowrap;}
29313 </style>
29314</head>
29315<body>
29316 {{ loading_overlay|safe }}
29317 <div class="background-watermarks" aria-hidden="true">
29318 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29319 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29320 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29321 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29322 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29323 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29324 </div>
29325 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
29326 <div class="top-nav">
29327 <div class="top-nav-inner">
29328 <a class="brand" href="/">
29329 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
29330 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan Delta</div></div>
29331 </a>
29332 <div class="nav-right">
29333 <a class="nav-pill" href="/">Home</a>
29334 <div class="nav-dropdown">
29335 <a href="/view-reports" class="nav-dropdown-btn">View Reports <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></a>
29336 <div class="nav-dropdown-menu">
29337 <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
29338 </div>
29339 </div>
29340 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
29341 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
29342 <div class="nav-dropdown">
29343 <a href="/git-browser" class="nav-dropdown-btn">Git Browser <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></a>
29344 <div class="nav-dropdown-menu">
29345 <a href="/integrations"><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>Integrations</a>
29346 </div>
29347 </div>
29348 <div class="server-status-wrap" id="server-status-wrap">
29349 <div class="nav-pill server-online-pill" id="server-status-pill">
29350 <span class="status-dot" id="status-dot"></span>
29351 <span id="server-status-label">Server</span>
29352 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
29353 </div>
29354 <div class="server-status-tip">
29355 OxideSLOC is running — accessible on your network.
29356 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
29357 </div>
29358 </div>
29359 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
29360 <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
29361 </button>
29362 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
29363 <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>
29364 <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>
29365 </button>
29366 </div>
29367 </div>
29368 </div>
29369
29370 <div class="page">
29371 <section class="hero">
29372 <div class="hero-header">
29373 <div>
29374 <h1 class="delta-title">Scan Delta</h1>
29375 <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
29376 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:6px;">
29377 {% if let Some(sub) = active_submodule %}
29378 <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
29379 {% else if super_scope_active %}
29380 <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
29381 {% else %}
29382 <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
29383 {% endif %}
29384 <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
29385 </div>
29386 </div>
29387 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;flex-shrink:0;">
29388 <a class="btn-back" href="/compare-scans">
29389 <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>
29390 Compare Scans
29391 </a>
29392 <div class="export-group" style="margin-top:12px;">
29393 <button type="button" class="export-btn" id="page-export-html-btn" title="Export page as HTML report"><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> Export HTML</button>
29394 <button type="button" class="export-btn" id="page-export-pdf-btn" title="Export page as PDF report"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg> Export PDF</button>
29395 </div>
29396 </div>
29397 </div>
29398 {% if has_any_submodule_data %}
29399 <div class="submod-scope-bar">
29400 <span class="submod-scope-label">
29401 <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>
29402 Scope:
29403 </span>
29404 <div class="submod-scope-divider"></div>
29405 <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
29406 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}"
29407 title="All files — super-repo and all submodules combined">Full scan</a>
29408 <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
29409 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&scope=super"
29410 title="Only files that are not part of any submodule">Super-repo only</a>
29411 {% for sub in submodule_options %}
29412 <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
29413 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&sub={{ sub }}"
29414 title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
29415 {% endfor %}
29416 </div>
29417 {% endif %}
29418 <div class="hero-body">
29419 <div class="meta-strip">
29420 <div class="delta-card delta-card-meta">
29421 <div class="meta-card-header">
29422 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
29423 <div class="meta-card-project-col">
29424 <div class="meta-card-project">{{ project_name }}</div>
29425 {% if has_any_submodule_data %}
29426 {% if let Some(sub) = active_submodule %}
29427 <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>
29428 {% else if super_scope_active %}
29429 <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>
29430 {% else %}
29431 <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>
29432 {% endif %}
29433 {% endif %}
29434 </div>
29435 </div>
29436 {% if !baseline_git_commit.is_empty() %}
29437 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
29438 {% else %}
29439 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
29440 {% endif %}
29441 <div class="meta-card-rows">
29442 <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>
29443 <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>
29444 <div class="meta-card-row"><span class="meta-label">Last commit by:</span>{% if let Some(author) = baseline_git_author %}<span class="meta-value"><span class="cmp-author-val">{{ author }}</span><span class="cmp-author-handle"></span></span>{% else %}<span class="meta-value">—</span>{% endif %}</div>
29445 <div class="meta-card-row"><span class="meta-label">Scanned on:</span><span class="meta-value ts-local" data-utc-ms="{{ baseline_timestamp_utc_ms }}">{{ baseline_timestamp }}</span></div>
29446 {% if let Some(tags) = baseline_git_tags %}
29447 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
29448 {% endif %}
29449 </div>
29450 </div>
29451 <div class="delta-card delta-card-meta">
29452 <div class="meta-card-header">
29453 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
29454 <div class="meta-card-project-col">
29455 <div class="meta-card-project">{{ project_name }}</div>
29456 {% if has_any_submodule_data %}
29457 {% if let Some(sub) = active_submodule %}
29458 <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>
29459 {% else if super_scope_active %}
29460 <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>
29461 {% else %}
29462 <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>
29463 {% endif %}
29464 {% endif %}
29465 </div>
29466 </div>
29467 {% if !current_git_commit.is_empty() %}
29468 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
29469 {% else %}
29470 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
29471 {% endif %}
29472 <div class="meta-card-rows">
29473 <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>
29474 <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>
29475 <div class="meta-card-row"><span class="meta-label">Last commit by:</span>{% if let Some(author) = current_git_author %}<span class="meta-value"><span class="cmp-author-val">{{ author }}</span><span class="cmp-author-handle"></span></span>{% else %}<span class="meta-value">—</span>{% endif %}</div>
29476 <div class="meta-card-row"><span class="meta-label">Scanned on:</span><span class="meta-value ts-local" data-utc-ms="{{ current_timestamp_utc_ms }}">{{ current_timestamp }}</span></div>
29477 {% if let Some(tags) = current_git_tags %}
29478 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
29479 {% endif %}
29480 </div>
29481 </div>
29482 </div>
29483 <div class="delta-strip">
29484 <div class="delta-card">
29485 <div class="dc-tip">Executable source lines.<br>Excludes comments and blanks.<br>Positive delta = more code written.</div>
29486 <div class="delta-card-label">Code lines</div>
29487 <div class="delta-card-from">Before: {{ baseline_code_fmt }}</div>
29488 <div class="delta-card-to">{{ current_code_fmt }}</div>
29489 {% 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>
29490 {% 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>
29491 {% else %}<div class="delta-card-pct zero">±0%</div>
29492 {% endif %}
29493 </div>
29494 <div class="delta-card">
29495 <div class="dc-tip">Source files where language detection succeeded.<br>Changes reflect files added, removed, or reclassified between scans.</div>
29496 <div class="delta-card-label">Files analyzed</div>
29497 <div class="delta-card-from">Before: {{ baseline_files_fmt }}</div>
29498 <div class="delta-card-to">{{ current_files_fmt }}</div>
29499 {% 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>
29500 {% 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>
29501 {% else %}<div class="delta-card-pct zero">±0%</div>
29502 {% endif %}
29503 </div>
29504 <div class="delta-card">
29505 <div class="dc-tip">Comment-only lines per the active parser policy.<br>A rise indicates more docs; a drop may reflect comment cleanup.</div>
29506 <div class="delta-card-label">Comment lines</div>
29507 <div class="delta-card-from">Before: {{ baseline_comments_fmt }}</div>
29508 <div class="delta-card-to">{{ current_comments_fmt }}</div>
29509 {% 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>
29510 {% 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>
29511 {% else %}<div class="delta-card-pct zero">±0%</div>
29512 {% endif %}
29513 </div>
29514 {{ coverage_delta_card|safe }}
29515 <div class="delta-card delta-card-wide">
29516 <div class="dc-tip">Per-file breakdown.<br>Modified = at least one count changed.<br>Unchanged = identical counts in both scans.<br>Added/Removed = only in one scan.</div>
29517 <div class="delta-card-label">File changes</div>
29518 <div class="file-changes-grid">
29519 <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified|commas }}</span><span class="fc-label">Modified</span></div>
29520 <div class="fc-row fc-added"><span class="fc-count">{{ files_added|commas }}</span><span class="fc-label">Added</span></div>
29521 <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed|commas }}</span><span class="fc-label">Removed</span></div>
29522 <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged|commas }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
29523 </div>
29524 </div>
29525 </div>
29526 <div class="insights-panel">
29527 <div class="insight-card">
29528 <div class="dc-tip up">Sum of code lines added or grown across all files between the two scans.<br>Only counts files where the current scan has more code than the baseline — shrunk files do not contribute here.</div>
29529 <div class="insight-label">Lines Added</div>
29530 <div class="insight-val pos">+{{ code_lines_added }}</div>
29531 <div class="insight-sub">New or grown source lines</div>
29532 </div>
29533 <div class="insight-card">
29534 <div class="dc-tip up">Sum of code lines removed or shrunk across all files between the two scans.<br>Only counts files where the current scan has fewer code lines than the baseline — grown files do not contribute here.</div>
29535 <div class="insight-label">Lines Removed</div>
29536 <div class="insight-val neg">−{{ code_lines_removed }}</div>
29537 <div class="insight-sub">Deleted or shrunk source lines</div>
29538 </div>
29539 <div class="insight-card">
29540 <div class="dc-tip up">Total current-scan code lines living in files that changed between the two scans.<br>Counts every code line in a modified file, not just the changed lines.</div>
29541 <div class="insight-label">Lines Modified</div>
29542 <div class="insight-val">{{ code_lines_modified }}</div>
29543 <div class="insight-sub">Code lines in modified files</div>
29544 </div>
29545 <div class="insight-card">
29546 <div class="dc-tip up">Code lines in files that are byte-for-byte identical (same code/comment/blank counts) in both scans.<br>These lines carried over unchanged.</div>
29547 <div class="insight-label">Lines Unmodified</div>
29548 <div class="insight-val">{{ code_lines_unmodified }}</div>
29549 <div class="insight-sub">Code lines in unchanged files</div>
29550 </div>
29551 <div class="insight-card">
29552 <div class="dc-tip up">Measures total editing activity relative to codebase size.<br>Formula: (lines added + lines removed) ÷ baseline code lines × 100%.<br>Above 20% = high activity<br>5–20% = normal velocity<br>Below 5% = stable baseline.</div>
29553 <div class="insight-label">Churn Rate</div>
29554 <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
29555 <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>
29556 </div>
29557 {% if scope_flag %}
29558 <div class="insight-card insight-flag">
29559 <div class="dc-tip up">{% if new_scope %}This scope had no files in the baseline scan — all content is new.<br>Switch to Full scan to compare against the parent repository.{% else %}Triggered when net code growth exceeds 20% of the baseline.<br>This often signals a large feature branch, a bulk import, or a generated-file inclusion.<br>Review the file-level delta below to confirm scope.{% endif %}</div>
29560 <div class="insight-label flag">Scope Signal</div>
29561 <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
29562 <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>
29563 </div>
29564 {% endif %}
29565 </div>
29566 </div>
29567 </section>
29568
29569 <section class="panel" id="inline-charts-section">
29570 <div class="panel-title">Scan Delta Charts</div>
29571 <div class="ic-grid">
29572 <div class="ic-card" style="grid-column:span 2">
29573 <div class="ic-card-h2-row">
29574 <span class="ic-card-h2">Timeline</span>
29575 <div class="cmp-tl-btns" style="display:flex;gap:6px;flex-wrap:wrap;">
29576 <button class="chart-metric-btn active" data-cmp-metric="code">Code Lines</button>
29577 <button class="chart-metric-btn" data-cmp-metric="files">Files</button>
29578 <button class="chart-metric-btn" data-cmp-metric="comments">Comments</button>
29579 <button class="chart-metric-btn" data-cmp-metric="tests">Tests</button>
29580 <button class="chart-metric-btn" data-cmp-metric="cov">Coverage</button>
29581 </div>
29582 <button class="ic-expand-btn" data-expand-src="cmp-tl-svg" data-expand-title="Timeline">⤢ Full View</button>
29583 </div>
29584 <div class="chart-wrap"><svg id="cmp-tl-svg" width="100%" height="280"></svg></div>
29585 </div>
29586 <div class="ic-card">
29587 <div class="ic-card-h2-row"><span class="ic-card-h2">Code Metrics — Baseline vs Current</span><button class="ic-expand-btn" data-expand-src="ic-c1" data-expand-title="Code Metrics — Baseline vs Current">⤢ Full View</button></div>
29588 <div class="ic-leg"><span class="ic-leg-item" data-highlight="Code Lines"><span class="ic-dot" style="background:#C45C10"></span><span style="color:#C45C10;font-weight:600">Code Lines</span></span><span class="ic-leg-item" data-highlight="Files Analyzed"><span class="ic-dot" style="background:#2A6846"></span><span style="color:#2A6846;font-weight:600">Files</span></span><span class="ic-leg-item" data-highlight="Comments"><span class="ic-dot" style="background:#D4A017"></span><span style="color:#D4A017;font-weight:600">Comments</span></span></div>
29589 <div id="ic-c1"></div>
29590 </div>
29591 <div class="ic-card" id="ic-lang-card">
29592 <div class="ic-card-h2-row"><span class="ic-card-h2">Language Code Delta</span><button class="ic-expand-btn" data-expand-src="ic-c3" data-expand-title="Language Code Delta">⤢ Full View</button></div>
29593 <div id="ic-c3"></div>
29594 </div>
29595 <div class="ic-card">
29596 <div class="ic-card-h2-row"><span class="ic-card-h2">Delta by Metric</span><button class="ic-expand-btn" data-expand-src="ic-c2" data-expand-title="Delta by Metric">⤢ Full View</button></div>
29597 <div id="ic-c2"></div>
29598 </div>
29599 <div class="ic-card">
29600 <div class="ic-card-h2-row"><span class="ic-card-h2">File Change Distribution</span><button class="ic-expand-btn" data-expand-src="ic-c4" data-expand-title="File Change Distribution">⤢ Full View</button></div>
29601 <div id="ic-c4"></div>
29602 </div>
29603 </div>
29604 <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
29605 <div class="ic-svg-modal">
29606 <div class="ic-svg-modal-hdr">
29607 <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
29608 <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">× Close</button>
29609 </div>
29610 <div id="ic-svg-modal-body"></div>
29611 </div>
29612 </div>
29613 </section>
29614
29615 <section class="panel">
29616 <div class="panel-title">File Matrix <span style="font-size:11px;font-weight:400;color:var(--muted);margin-left:8px;text-transform:none;letter-spacing:0;">{{ (files_modified + files_added + files_removed + files_unchanged)|commas }} files</span></div>
29617 <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
29618 <div class="filter-tabs" style="display:flex;gap:6px;flex-wrap:wrap;">
29619 <button class="tab-btn tab-all active" data-filter="all">All ({{ (files_modified + files_added + files_removed + files_unchanged)|commas }})</button>
29620 <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified|commas }})</button>
29621 <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added|commas }})</button>
29622 <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed|commas }})</button>
29623 <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged|commas }})</button>
29624 </div>
29625 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
29626 <span class="delta-note">* Δ = delta (change from baseline → current)</span>
29627 <div class="export-group">
29628 <button type="button" class="export-btn" id="delta-reset-btn">↻ Reset</button>
29629 <button type="button" class="export-btn" id="delta-csv-btn">
29630 <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>
29631 CSV
29632 </button>
29633 <button type="button" class="export-btn" id="delta-xls-btn">
29634 <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>
29635 Excel
29636 </button>
29637 </div>
29638 </div>
29639 </div>
29640
29641 <div class="table-wrap">
29642 <table id="delta-table">
29643 <colgroup>
29644 <col>
29645 <col>
29646 <col>
29647 <col>
29648 <col>
29649 <col>
29650 <col>
29651 </colgroup>
29652 <thead>
29653 <tr id="delta-thead">
29654 <th class="sortable" data-sort-col="path" data-sort-type="str">File<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
29655 <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>
29656 <th class="sortable" data-sort-col="status" data-sort-type="str">Status<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
29657 <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>
29658 <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>
29659 <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>
29660 <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>
29661 </tr>
29662 </thead>
29663 <tbody id="delta-tbody">
29664 {% for row in file_rows %}
29665 <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
29666 data-path="{{ row.relative_path }}"
29667 data-language="{{ row.language }}"
29668 data-baseline-code="{{ row.baseline_code }}"
29669 data-current-code="{{ row.current_code }}"
29670 data-code-delta="{{ row.code_delta_str }}"
29671 data-comment-delta="{{ row.comment_delta_str }}"
29672 data-total-delta="{{ row.total_delta_str }}"
29673 data-orig-idx="">
29674 <td title="{{ row.relative_path }}"><span class="file-path">{{ row.relative_path }}</span></td>
29675 <td class="hide-sm">{{ row.language }}</td>
29676 <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
29677 <td><span class="from-to" data-baseline="{{ row.baseline_code }}" data-current="{{ row.current_code }}">{% if row.baseline_code_display == "—" %}<span class="ft-absent">—</span>{% else %}<strong>{{ row.baseline_code_display }}</strong>{% endif %}<span class="ft-sep">→</span>{% if row.current_code_display == "—" %}<span class="ft-absent">—</span>{% else %}<strong>{{ row.current_code_display }}</strong>{% endif %}</span></td>
29678 <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
29679 <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
29680 <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
29681 </tr>
29682 {% endfor %}
29683 </tbody>
29684 </table>
29685 </div>
29686 <div class="pagination">
29687 <span class="pagination-info" id="pg-range-label"></span>
29688 <div class="pagination-btns" id="pg-btns"></div>
29689 <div class="flex-row">
29690 <span class="per-page-label">Show</span>
29691 <select class="per-page" id="per-page-sel">
29692 <option value="10">10 per page</option>
29693 <option value="25" selected>25 per page</option>
29694 <option value="50">50 per page</option>
29695 <option value="100">100 per page</option>
29696 </select>
29697 </div>
29698 </div>
29699 </section>
29700 </div>
29701
29702 <div id="ic-tt"></div>
29703
29704 <footer class="site-footer">
29705 local code analysis - metrics, history and reports
29706 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
29707 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
29708 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
29709 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
29710 · <a href="/api-docs" rel="noopener">REST API</a>
29711 </footer>
29712
29713 <script nonce="{{ csp_nonce }}">
29714 (function () {
29715 var storageKey = 'oxide-sloc-theme';
29716 var body = document.body;
29717 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
29718 var toggle = document.getElementById('theme-toggle');
29719 if (toggle) toggle.addEventListener('click', function () {
29720 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
29721 body.classList.toggle('dark-theme', next === 'dark');
29722 try { localStorage.setItem(storageKey, next); } catch(e) {}
29723 });
29724
29725 (function randomizeWatermarks() {
29726 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
29727 if (!wms.length) return;
29728 var placed = [];
29729 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;}
29730 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];}
29731 var half=Math.floor(wms.length/2);
29732 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;});
29733 })();
29734
29735 (function spawnCodeParticles() {
29736 var container = document.getElementById('code-particles');
29737 if (!container) return;
29738 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'];
29739 for (var i = 0; i < 38; i++) {
29740 (function(idx) {
29741 var el = document.createElement('span');
29742 el.className = 'code-particle';
29743 el.textContent = snippets[idx % snippets.length];
29744 var left = Math.random() * 94 + 2;
29745 var top = Math.random() * 88 + 6;
29746 var dur = (Math.random() * 10 + 9).toFixed(1);
29747 var delay = (Math.random() * 18).toFixed(1);
29748 var rot = (Math.random() * 26 - 13).toFixed(1);
29749 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
29750 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';
29751 container.appendChild(el);
29752 })(i);
29753 }
29754 })();
29755 })();
29756
29757 var activeStatusFilter = 'all';
29758 var deltaPerPage = 25, deltaCurrPage = 1;
29759
29760 function openFolder(path) {
29761 fetch('/open-path?path=' + encodeURIComponent(path))
29762 .then(function (r) { return r.json(); })
29763 .then(function (d) {
29764 if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
29765 })
29766 .catch(function () {});
29767 }
29768
29769 // \u2500\u2500 File-matrix model (windowed render) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
29770 // The server renders every row once; we lift them into a plain-data array and
29771 // then clear the DOM so only the visible page's <tr>s ever exist. Sorting and
29772 // filtering run on the array (no DOM churn) and each render rebuilds just one
29773 // page (~25 rows). This keeps every interaction O(page) instead of O(all
29774 // files): a 28k-row table previously re-touched every node on each click
29775 // (querySelectorAll x2, appendChild x28k to sort) and froze the page.
29776 var DELTA = [], _deltaView = [], sortCol = null, sortOrder = 'asc';
29777
29778 function parseDeltaNum(str) {
29779 if (!str || str === '\u2014') return 0;
29780 return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().charAt(0) === '-' ? -1 : 1);
29781 }
29782
29783 function captureDelta() {
29784 var tbody = document.getElementById('delta-tbody');
29785 if (!tbody) return;
29786 var rows = tbody.querySelectorAll('.delta-row');
29787 for (var i = 0; i < rows.length; i++) {
29788 var r = rows[i];
29789 DELTA.push({
29790 h: r.innerHTML,
29791 cls: r.className,
29792 path: r.getAttribute('data-path') || '',
29793 lang: r.getAttribute('data-language') || '',
29794 status: r.getAttribute('data-status') || '',
29795 bc: parseFloat(r.getAttribute('data-baseline-code')) || 0,
29796 cc: parseFloat(r.getAttribute('data-current-code')) || 0,
29797 cd: parseDeltaNum(r.getAttribute('data-code-delta')),
29798 cmd: parseDeltaNum(r.getAttribute('data-comment-delta')),
29799 td: parseDeltaNum(r.getAttribute('data-total-delta')),
29800 bcs: r.getAttribute('data-baseline-code') || '',
29801 ccs: r.getAttribute('data-current-code') || '',
29802 cds: r.getAttribute('data-code-delta') || '',
29803 cmds: r.getAttribute('data-comment-delta') || '',
29804 tds: r.getAttribute('data-total-delta') || ''
29805 });
29806 }
29807 tbody.innerHTML = '';
29808 }
29809
29810 function applyDeltaQuery() {
29811 var v = (activeStatusFilter === 'all') ? DELTA.slice()
29812 : DELTA.filter(function(d) { return d.status === activeStatusFilter; });
29813 if (sortCol) {
29814 var asc = sortOrder === 'asc';
29815 v.sort(function(a, b) {
29816 var va, vb;
29817 if (sortCol === 'path') { va = a.path; vb = b.path; }
29818 else if (sortCol === 'language') { va = a.lang; vb = b.lang; }
29819 else if (sortCol === 'status') { va = a.status; vb = b.status; }
29820 else if (sortCol === 'baseline_code') { return asc ? a.bc - b.bc : b.bc - a.bc; }
29821 else if (sortCol === 'code_delta') { return asc ? a.cd - b.cd : b.cd - a.cd; }
29822 else if (sortCol === 'comment_delta') { return asc ? a.cmd - b.cmd : b.cmd - a.cmd; }
29823 else if (sortCol === 'total_delta') { return asc ? a.td - b.td : b.td - a.td; }
29824 else { return 0; }
29825 if (asc) return va < vb ? -1 : va > vb ? 1 : 0;
29826 return va < vb ? 1 : va > vb ? -1 : 0;
29827 });
29828 }
29829 _deltaView = v;
29830 deltaCurrPage = 1;
29831 renderDeltaPage();
29832 }
29833
29834 function renderDeltaPage() {
29835 var total = _deltaView.length;
29836 var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
29837 if (deltaCurrPage > totalPages) deltaCurrPage = totalPages;
29838 if (deltaCurrPage < 1) deltaCurrPage = 1;
29839 var start = (deltaCurrPage - 1) * deltaPerPage;
29840 var end = Math.min(start + deltaPerPage, total);
29841 var tbody = document.getElementById('delta-tbody');
29842 if (tbody) {
29843 var html = '';
29844 for (var i = start; i < end; i++) { var d = _deltaView[i]; html += '<tr class="' + d.cls + '">' + d.h + '</tr>'; }
29845 tbody.innerHTML = html;
29846 }
29847 var rl = document.getElementById('pg-range-label');
29848 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total + ' files' : 'No results';
29849 var btns = document.getElementById('pg-btns');
29850 if (!btns) return;
29851 btns.innerHTML = '';
29852 if (totalPages <= 1) return;
29853 function makeBtn(lbl, pg, active, disabled) {
29854 var b = document.createElement('button');
29855 b.className = 'pg-btn' + (active ? ' active' : '');
29856 b.textContent = lbl; b.disabled = disabled;
29857 if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
29858 return b;
29859 }
29860 btns.appendChild(makeBtn('\u2039', deltaCurrPage - 1, false, deltaCurrPage === 1));
29861 var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
29862 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
29863 btns.appendChild(makeBtn('\u203a', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
29864 }
29865
29866 window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
29867
29868 function filterRows(status, btn) {
29869 activeStatusFilter = status;
29870 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
29871 b.classList.remove('active');
29872 });
29873 if (btn) btn.classList.add('active');
29874 applyDeltaQuery();
29875 }
29876
29877 // ── Sorting ──────────────────────────────────────────────────────────────
29878 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
29879 sortHeaders.forEach(function(th) {
29880 th.addEventListener('click', function(e) {
29881 if (e.target.classList.contains('col-resize-handle')) return;
29882 var col = th.dataset.sortCol;
29883 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
29884 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
29885 th.classList.add('sort-' + sortOrder);
29886 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
29887 applyDeltaQuery();
29888 });
29889 });
29890
29891 // ── Column resize ─────────────────────────────────────────────────────────
29892 (function() {
29893 var table = document.getElementById('delta-table');
29894 if (!table) return;
29895 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
29896 var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
29897 ths.forEach(function(th, i) {
29898 var handle = th.querySelector('.col-resize-handle');
29899 if (!handle || !cols[i]) return;
29900 handle.addEventListener('mousedown', function(e) {
29901 e.stopPropagation(); e.preventDefault();
29902 // Lock every column to its current rendered px width and size the table
29903 // to the column total. With table-layout:fixed + width:100% the table is
29904 // pinned to the container, so widening one <col> only rebalances the rest
29905 // and the drag looks inert; pinning px widths lets the column actually
29906 // grow while the wrapper (overflow-x:auto) scrolls.
29907 var startTableW = 0;
29908 for (var k = 0; k < ths.length; k++) {
29909 if (!cols[k]) continue;
29910 var w = ths[k].getBoundingClientRect().width;
29911 cols[k].style.width = w + 'px';
29912 startTableW += w;
29913 }
29914 table.style.width = startTableW + 'px';
29915 var startX = e.clientX;
29916 var startW = ths[i].getBoundingClientRect().width;
29917 handle.classList.add('dragging');
29918 function onMove(ev) {
29919 var newW = Math.max(40, startW + ev.clientX - startX);
29920 cols[i].style.width = newW + 'px';
29921 table.style.width = (startTableW + (newW - startW)) + 'px';
29922 }
29923 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
29924 document.addEventListener('mousemove', onMove);
29925 document.addEventListener('mouseup', onUp);
29926 });
29927 });
29928 })();
29929
29930 // ── Reset ─────────────────────────────────────────────────────────────────
29931 window.resetDeltaTable = function() {
29932 sortCol = null; sortOrder = 'asc';
29933 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
29934 var table = document.getElementById('delta-table');
29935 if (table) { table.style.width = ''; Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; }); }
29936 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
29937 activeStatusFilter = 'all';
29938 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
29939 var allBtn = document.querySelector('.tab-btn');
29940 if (allBtn) allBtn.classList.add('active');
29941 applyDeltaQuery();
29942 };
29943
29944 // Compact number formatter (shared by the delta table; charts define their own locally)
29945 function fmt(n){var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}
29946 function fmtFull(n){return Number(n).toLocaleString();}
29947
29948 // Format from-to numbers with fmt() and ensure zero→dash for added/removed
29949 function fmtFromTo() {
29950 var tbody = document.getElementById('delta-tbody');
29951 if (!tbody) return;
29952 tbody.querySelectorAll('.delta-row').forEach(function(row) {
29953 var status = row.dataset.status || '';
29954 var ft = row.querySelector('.from-to');
29955 if (!ft) return;
29956 var bv = parseInt(ft.getAttribute('data-baseline') || '0', 10);
29957 var cv = parseInt(ft.getAttribute('data-current') || '0', 10);
29958 var strongs = ft.querySelectorAll('strong');
29959 // Apply fmt() to non-absent strong values
29960 strongs.forEach(function(el) {
29961 var n = parseInt(el.textContent, 10);
29962 if (!isNaN(n)) el.textContent = fmtFull(n);
29963 });
29964 // Safety: force dash for genuinely absent sides
29965 if (status === 'added' && bv === 0) {
29966 var bs = ft.querySelector('strong:first-of-type');
29967 if (bs && bs.textContent === '0') {
29968 bs.outerHTML = '<span class="ft-absent">\u2014</span>';
29969 }
29970 }
29971 if (status === 'removed' && cv === 0) {
29972 var cs = ft.querySelector('strong:last-of-type');
29973 if (cs && cs.textContent === '0') {
29974 cs.outerHTML = '<span class="ft-absent">\u2014</span>';
29975 }
29976 }
29977 });
29978 }
29979 // Initialize: format the server-rendered rows, lift them into the data model
29980 // (which also clears the DOM), then render only the first page.
29981 fmtFromTo();
29982 captureDelta();
29983 applyDeltaQuery();
29984
29985 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
29986 (function() {
29987 Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
29988 btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
29989 });
29990 var resetBtn = document.getElementById('delta-reset-btn');
29991 if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
29992 var csvBtn = document.getElementById('delta-csv-btn');
29993 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
29994 var xlsBtn = document.getElementById('delta-xls-btn');
29995 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
29996 // ── Export helpers (image-inlining + pdf-mode) ────────────────────────────
29997 function sdFetchUri(path) {
29998 return fetch(path).then(function(r){return r.blob();}).then(function(b){
29999 return new Promise(function(res){var rd=new FileReader();rd.onload=function(){res(rd.result);};rd.onerror=function(){res('');};rd.readAsDataURL(b);});
30000 }).catch(function(){return '';});
30001 }
30002 function sdInlineImgs(html, cb) {
30003 var paths=[], seen={};
30004 html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){if(!seen[p]){seen[p]=1;paths.push(p);}return _;});
30005 if(!paths.length){cb(html);return;}
30006 Promise.all(paths.map(function(p){return sdFetchUri(p).then(function(u){return{p:p,u:u};});}))
30007 .then(function(rs){rs.forEach(function(r){if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');});cb(html);})
30008 .catch(function(){cb(html);});
30009 }
30010 function buildFullPageHtml(pdfMode) {
30011 if(pdfMode) document.body.classList.add('pdf-mode');
30012 var saved = deltaPerPage; deltaPerPage = 999999; deltaCurrPage = 1;
30013 renderDeltaPage();
30014 var html = document.documentElement.outerHTML;
30015 deltaPerPage = saved; deltaCurrPage = 1; renderDeltaPage();
30016 if(pdfMode) document.body.classList.remove('pdf-mode');
30017 return html;
30018 }
30019 var chartsBtn = document.getElementById('delta-charts-btn');
30020 if (chartsBtn) chartsBtn.addEventListener('click', function() {
30021 var btn=chartsBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
30022 sdInlineImgs(buildFullPageHtml(false), function(html) {
30023 var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
30024 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
30025 a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
30026 btn.disabled=false;btn.innerHTML=orig;
30027 });
30028 });
30029 var pageHtmlBtn = document.getElementById('page-export-html-btn');
30030 if (pageHtmlBtn) pageHtmlBtn.addEventListener('click', function() {
30031 var btn=pageHtmlBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
30032 sdInlineImgs(buildFullPageHtml(false), function(html) {
30033 var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
30034 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
30035 a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
30036 btn.disabled=false;btn.innerHTML=orig;
30037 });
30038 });
30039 // PDF export — clean document-style report, not a web page screenshot
30040 function buildDeltaPdfHtml() {
30041 var sd=_sd, dr=getDeltaExportRows();
30042 var dchg=dr.filter(function(r){return (r[2]||'')!=='unchanged';});
30043 function pct(b,c){b=Number(b);c=Number(c);if(!b)return c>0?'new':'±0%';var v=(c-b)/b*100,t=v.toFixed(1);return(t==='0.0'||t==='-0.0')?'±0%':(v>0?'+':'')+t+'%';}
30044 function pcls(b,c){var v=Number(c)-Number(b);return v>0?'pos':(v<0?'neg':'zero');}
30045 var projEl=document.querySelector('[data-folder]'), proj=projEl?projEl.getAttribute('data-folder'):'';
30046 var projName=proj?(String(proj).replace(/[\\/]+$/,'').split(/[\\/]/).pop()||proj):proj;
30047 var tz;try{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){tz='America/Los_Angeles';}
30048 var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
30049 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
30050 function fmtN(n){return Number(n).toLocaleString();}
30051 function fullN(n){var v=Number(n);return isNaN(v)?'\u2014':v.toLocaleString();}
30052 function delt(v){var s=String(v==null?'\u2014':v);if(!s||s==='0'||s==='\u2014')return'<span>'+esc(s)+'</span>';return s.charAt(0)==='-'?'<span style="color:#b23030;font-weight:700">'+esc(s)+'</span>':'<span style="color:#2a6846;font-weight:700">'+esc(s)+'</span>';}
30053 var lm={};
30054 dr.forEach(function(r){var l=r[1]||'Unknown',d=parseInt(r[5])||0,c=parseInt(r[4])||0;if(!lm[l])lm[l]={f:0,d:0,c:0};lm[l].f++;lm[l].d+=d;lm[l].c+=c;});
30055 var langs=Object.keys(lm).sort(function(a,b){return lm[b].c-lm[a].c;}).slice(0,15);
30056 var tfTotal=sd.fm+sd.fa+sd.fr+sd.fu;
30057 // The header/footer flow in normal document order (NOT position:fixed).
30058 // A fixed header repeats on every printed page in Chromium and overlaps
30059 // the content beneath it — silently swallowing the first few table rows of
30060 // pages 2+ and clipping the summary cards on page 1. Letting the header
30061 // flow once at the top and relying on the table's <thead> (which Chromium
30062 // repeats per page) keeps every row visible. `.body` keeps a small inset
30063 // so nothing bleeds to the sheet edge.
30064 var css='body{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}'+
30065 '.pdf-header{-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
30066 '.pdf-footer{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
30067 '.page-hdr{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}'+
30068 '.ph-brand{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}'+
30069 '.ph-brand em{color:#c45c10;font-style:normal;}'+
30070 '.ph-title{font-size:14px;font-weight:600;color:#555;}'+
30071 '.ph-date{font-size:11px;color:#888;text-align:right;white-space:nowrap;}'+
30072 '.info-bar{background:#1a2035;color:#fff;padding:7px 14px;display:flex;justify-content:space-between;align-items:center;gap:10px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
30073 '.ib-name{font-size:13px;font-weight:800;color:#fff;}'+
30074 '.ib-path{font-size:10px;color:#8899aa;margin-top:2px;}'+
30075 '.ib-right{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}'+
30076 '.ftr{background:#1a2035;color:#7a8b9c;font-size:10px;padding:5px 14px;display:flex;justify-content:space-between;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
30077 '.body{padding:12px 18px 0;}'+
30078 '.sg{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}'+
30079 '.sc{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}'+
30080 '.sv{font-size:18px;font-weight:900;color:#c45c10;}'+
30081 '.sl{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}'+
30082 '.meta{background:#f5f2ee;border:1px solid #e5e0d8;border-radius:6px;padding:8px 12px;margin-bottom:10px;display:flex;justify-content:space-between;align-items:center;gap:10px;text-align:center;}'+
30083 '.meta>div{flex:1 1 0;}'+
30084 '.ml{color:#888;font-size:10px;text-transform:uppercase;letter-spacing:.06em;}.mv{font-weight:700;margin-top:3px;font-size:15px;}'+
30085 '.sec{margin-bottom:10px;}'+
30086 '.sh{background:#1a2035;color:#fff;padding:4px 8px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;margin:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
30087 '.pg-rhdr th{background:#0f1420;color:#fff;padding:0;border:none;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
30088 '.pg-rhdr-in{display:flex;justify-content:space-between;align-items:center;padding:6px 11px;font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;}'+
30089 '.pg-rhdr-in em{color:#c45c10;font-style:normal;}'+
30090 '.pg-rhdr-r{color:#9fb0c8;font-weight:600;text-transform:none;letter-spacing:0;}'+
30091 'table{width:100%;border-collapse:collapse;font-size:12px;}'+
30092 'th{background:#1a2035;color:#fff;padding:4px 8px;font-size:11px;font-weight:700;text-align:left;letter-spacing:.03em;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
30093 'td{border-bottom:1px solid #eee;padding:3px 8px;vertical-align:middle;}'+
30094 'tr:nth-child(even) td{background:#faf8f6;}'+
30095 '.rfoot{position:fixed;left:0;right:0;bottom:0;height:20px;background:#1a2035;color:#9fb0c8;font-size:9px;display:flex;justify-content:space-between;align-items:center;padding:0 14px;box-sizing:border-box;z-index:99;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
30096 '.rfoot-spacer{height:30px!important;border:none!important;padding:0!important;background:#fff!important;}'+
30097 '.msec{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
30098 '.mcard{border:1px solid #ddd;border-radius:8px;padding:8px 11px;}'+
30099 '.mc-l{font-size:9px;font-weight:700;text-transform:uppercase;color:#888;letter-spacing:.05em;}'+
30100 '.mc-v{font-size:17px;font-weight:900;color:#1a2035;margin-top:3px;}'+
30101 '.mc-b{font-size:10px;color:#999;margin-top:2px;}'+
30102 '.mc-p{font-size:11px;font-weight:700;margin-top:2px;}'+
30103 '.mc-p.pos{color:#2a6846;}.mc-p.neg{color:#b23030;}.mc-p.zero{color:#999;}'+
30104 '.fcsec{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
30105 '.fcc{border:1px solid #e5e0d8;border-radius:8px;padding:8px 11px;display:flex;align-items:center;gap:9px;background:#faf8f6;}'+
30106 '.fcc-n{font-size:18px;font-weight:900;}'+
30107 '.fcc-l{font-size:10px;font-weight:600;color:#666;line-height:1.25;}';
30108 var fileRows=dchg.map(function(r){
30109 var st=r[2]||'',ss=st==='added'?'color:#2a6846;font-weight:700':st==='removed'?'color:#b23030;font-weight:700':'';
30110 return '<tr><td style="word-break:break-all">'+esc(r[0])+'</td><td>'+esc(r[1])+'</td>'+
30111 '<td style="'+ss+'">'+esc(st)+'</td>'+
30112 '<td style="text-align:right">'+fmtN(r[3])+'</td>'+
30113 '<td style="text-align:right">'+fmtN(r[4])+'</td>'+
30114 '<td style="text-align:right">'+delt(r[5])+'</td></tr>';
30115 }).join('')||'<tr><td colspan="6" style="text-align:center;color:#888;font-style:italic;padding:10px">No file changes between these scans.</td></tr>';
30116 var more='';
30117 var langRows=langs.map(function(l){var e=lm[l],dv=e.d>=0?'+'+e.d:String(e.d);return'<tr><td>'+esc(l)+'</td><td style="text-align:right">'+fmtN(e.f)+'</td><td style="text-align:right">'+fmtN(e.c)+'</td><td style="text-align:right">'+delt(dv)+'</td></tr>';}).join('');
30118 var extraCards='';
30119 if(Number(sd.btests||0)>0||Number(sd.ctests||0)>0){extraCards+='<div class="mcard"><div class="mc-l">Tests Detected</div><div class="mc-v">'+fullN(sd.ctests)+'</div><div class="mc-b">Before: '+fullN(sd.btests)+'</div><div class="mc-p '+pcls(sd.btests,sd.ctests)+'">'+pct(sd.btests,sd.ctests)+'</div></div>';}
30120 if(sd.bcov!=null||sd.ccov!=null){var _cc=(sd.ccov!=null?Number(sd.ccov).toFixed(1)+'%':'—'),_cb=(sd.bcov!=null?Number(sd.bcov).toFixed(1)+'%':'—');extraCards+='<div class="mcard"><div class="mc-l">Coverage</div><div class="mc-v">'+_cc+'</div><div class="mc-b">Before: '+_cb+'</div></div>';}
30121 return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta</title><style>'+css+'</style></head><body>'+
30122 '<div class="pdf-header">'+
30123 '<div class="page-hdr"><div class="ph-brand"><em>oxide</em>-sloc</div><div class="ph-title">Scan Delta</div><div class="ph-date">'+esc(now)+'</div></div>'+
30124 '<div class="info-bar"><div><div class="ib-name">'+esc(projName)+'</div><div class="ib-path">'+esc(proj)+'</div></div>'+
30125 '<div class="ib-right">Baseline: '+esc(_blabel)+'<br>Current: '+esc(_clabel)+'</div></div>'+
30126 '</div>'+
30127 '<div class="body">'+
30128 '<div class="sec"><p class="sh">Summary Metrics</p>'+
30129 '<div class="msec">'+
30130 '<div class="mcard"><div class="mc-l">Code Lines</div><div class="mc-v">'+fullN(sd.cc)+'</div><div class="mc-b">Before: '+fullN(sd.bc)+'</div><div class="mc-p '+pcls(sd.bc,sd.cc)+'">'+pct(sd.bc,sd.cc)+'</div></div>'+
30131 '<div class="mcard"><div class="mc-l">Files Analyzed</div><div class="mc-v">'+fullN(sd.cf)+'</div><div class="mc-b">Before: '+fullN(sd.bf)+'</div><div class="mc-p '+pcls(sd.bf,sd.cf)+'">'+pct(sd.bf,sd.cf)+'</div></div>'+
30132 '<div class="mcard"><div class="mc-l">Comment Lines</div><div class="mc-v">'+fullN(sd.ccm)+'</div><div class="mc-b">Before: '+fullN(sd.bcm)+'</div><div class="mc-p '+pcls(sd.bcm,sd.ccm)+'">'+pct(sd.bcm,sd.ccm)+'</div></div>'+
30133 '<div class="mcard"><div class="mc-l">Lines Added</div><div class="mc-v" style="color:#2a6846">+'+fullN(sd.cla)+'</div><div class="mc-b">New or grown source lines</div></div>'+
30134 '<div class="mcard"><div class="mc-l">Lines Removed</div><div class="mc-v" style="color:#b23030">−'+fullN(sd.clr)+'</div><div class="mc-b">Deleted or shrunk source lines</div></div>'+
30135 '<div class="mcard"><div class="mc-l">Churn Rate</div><div class="mc-v" style="color:#1a2035">'+esc(String(sd.churn))+'</div><div class="mc-b">(added + removed) ÷ baseline</div></div>'+
30136 extraCards+'</div></div>'+
30137 '<div class="sec"><p class="sh">File Changes</p>'+
30138 '<div class="fcsec">'+
30139 '<div class="fcc"><span class="fcc-n" style="color:#d4a017">'+fullN(sd.fm)+'</span><span class="fcc-l">Modified</span></div>'+
30140 '<div class="fcc"><span class="fcc-n" style="color:#2a6846">'+fullN(sd.fa)+'</span><span class="fcc-l">Added</span></div>'+
30141 '<div class="fcc"><span class="fcc-n" style="color:#b23030">'+fullN(sd.fr)+'</span><span class="fcc-l">Removed</span></div>'+
30142 '<div class="fcc"><span class="fcc-n" style="color:#555">'+fullN(sd.fu)+'</span><span class="fcc-l">Unchanged (identical code counts)</span></div>'+
30143 '</div></div>'+
30144 (langs.length?'<div class="sec"><p class="sh">Language Breakdown</p><table><thead><tr><th>Language</th><th style="text-align:right">Files</th><th style="text-align:right">Code Lines</th><th style="text-align:right">Code \u0394</th></tr></thead><tbody>'+langRows+'</tbody></table></div>':'')+
30145 '<div class="sec">'+
30146 '<table><thead>'+
30147 '<tr class="pg-rhdr"><th colspan="6"><div class="pg-rhdr-in"><span>File Delta · '+fmtN(dchg.length)+' changed of '+fmtN(dr.length)+' files</span><span class="pg-rhdr-r"><em>oxide</em>-sloc · Scan Delta · '+esc(projName)+'</span></div></th></tr>'+
30148 '<tr><th>File</th><th>Language</th><th>Status</th>'+
30149 '<th style="text-align:right">Code Before</th><th style="text-align:right">Code After</th><th style="text-align:right">Code \u0394</th>'+
30150 '</tr></thead><tbody>'+fileRows+more+'</tbody><tfoot><tr><td colspan="6" class="rfoot-spacer"></td></tr></tfoot></table></div>'+
30151 '</div>'+
30152 '<div class="rfoot">'+
30153 '<span>oxide-sloc v{{ version }} | AGPL-3.0-or-later</span><span>Scan Delta Report</span>'+
30154 '<span>'+esc(sd.bid)+' → '+esc(sd.cid)+'</span>'+
30155 '</div>'+
30156 '</body></html>';
30157 }
30158 function doDeltaPdf(btn) {
30159 window.slocExportPdf({html:buildDeltaPdfHtml(),filename:getExportFilename('pdf'),button:btn});
30160 }
30161 var pdfBtn = document.getElementById('delta-pdf-btn');
30162 if (pdfBtn) pdfBtn.addEventListener('click', function() { doDeltaPdf(pdfBtn); });
30163 var pagePdfBtn = document.getElementById('page-export-pdf-btn');
30164 if (pagePdfBtn) pagePdfBtn.addEventListener('click', function() { doDeltaPdf(pagePdfBtn); });
30165 if (location.protocol === 'file:') {
30166 [pageHtmlBtn, chartsBtn].forEach(function(b) { if (b) { b.disabled=true; b.style.opacity='0.45'; b.style.cursor='not-allowed'; b.title='Already viewing an exported HTML file'; b.textContent='Export HTML'; } });
30167 [pdfBtn, pagePdfBtn].forEach(function(b) { if (b) { b.disabled=true; b.style.opacity='0.45'; b.style.cursor='not-allowed'; b.title='PDF export requires a running server'; b.textContent='Export PDF'; } });
30168 }
30169 var ppSel = document.getElementById('per-page-sel');
30170 if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
30171 var pathLink = document.getElementById('project-path-link');
30172 if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
30173 })();
30174
30175 // ── Export helpers ────────────────────────────────────────────────────────
30176 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
30177 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
30178 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);}
30179 function slocMakeXlsx(fname,sd,dr){
30180 var enc=new TextEncoder();
30181 // CRC-32 table
30182 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;}
30183 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;}
30184 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
30185 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
30186 // Shared string table
30187 var ss=[],si={};
30188 function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
30189 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
30190 // Worksheet builder — each WS() call gets its own row counter R
30191 function WS(){
30192 var R=0,buf=[];
30193 function cl(c){return String.fromCharCode(65+c);}
30194 function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
30195 '<v>'+S(v)+'</v></c>';}
30196 function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
30197 (st?' s="'+st+'"':'')+'>'+
30198 '<v>'+(+v)+'</v></c>';}
30199 function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
30200 function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
30201 '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
30202 '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
30203 '<sheetFormatPr defaultRowHeight="15"/>'+
30204 (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
30205 return{sc:sc,nc:nc,row:row,xml:xml};
30206 }
30207 // Language breakdown
30208 var lm={};
30209 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;});
30210 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
30211 var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
30212 // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
30213 function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
30214 function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
30215 function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
30216 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):'';}
30217 function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
30218 // Summary sheet
30219 var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
30220 r1(s1(0,'OxideSLOC \u2014 Scan Delta Report',1));
30221 r1(s1(0,proj,2));
30222 r1(s1(0,sd.bts+' \u2192 '+sd.cts,2));
30223 r1('');
30224 r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
30225 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))));
30226 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))));
30227 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))));
30228 r1('');
30229 r1(s1(0,'FILE CHANGES',8));
30230 r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
30231 r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
30232 r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
30233 r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
30234 r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
30235 if(langs.length){
30236 r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
30237 r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
30238 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)));});
30239 }
30240 r1('');r1(s1(0,'SCAN METADATA',8));
30241 r1(s1(1,_blabel)+s1(2,_clabel));
30242 r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
30243 r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
30244 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"/>');
30245 // File Delta sheet
30246 var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
30247 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));
30248 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)));});
30249 var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
30250 // Shared strings XML
30251 var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
30252 '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
30253 ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
30254 // XLSX file map
30255 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
30256 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>',
30257 '_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>',
30258 '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>',
30259 '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>',
30260 '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>',
30261 'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
30262 // ZIP packer — STORED (no compression), compatible with all XLSX readers
30263 var zparts=[],zcds=[],zoff=0,znf=0;
30264 ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
30265 'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
30266 ].forEach(function(name){
30267 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
30268 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]);
30269 var entry=new Uint8Array(lha.length+nb.length+sz);
30270 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
30271 zparts.push(entry);
30272 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));
30273 var cde=new Uint8Array(cda.length+nb.length);
30274 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
30275 zcds.push(cde);zoff+=entry.length;znf++;
30276 });
30277 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
30278 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]);
30279 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
30280 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
30281 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
30282 zout.set(new Uint8Array(ea),zpos);
30283 var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
30284 var xurl=URL.createObjectURL(xblob);
30285 var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
30286 document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
30287 setTimeout(function(){URL.revokeObjectURL(xurl);},200);
30288 }
30289 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;');}
30290 var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
30291 function getExportFilename(ext){return _exportBase+'.'+ext;}
30292
30293 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 }}',btests:{{ baseline_test_count }},ctests:{{ current_test_count }},bcov:{% if let Some(p) = baseline_coverage_pct %}{{ p }}{% else %}null{% endif %},ccov:{% if let Some(p) = current_coverage_pct %}{{ p }}{% else %}null{% endif %},cla:{{ code_lines_added }},clr:{{ code_lines_removed }},churn:'{{ churn_rate_str }}'};
30294 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;}
30295 var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
30296 var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
30297 function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
30298 function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
30299 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):'';}
30300 var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
30301 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)]];}
30302 var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
30303 function getDeltaExportRows(){return DELTA.map(function(d){var b=parseInt(d.bcs)||0,c=parseInt(d.ccs)||0;return [d.path,d.lang,d.status,d.bcs,d.ccs,d.cds,d.cmds,d.tds,_filePct(b,c,d.status)];});}
30304 window.exportDeltaCsv = function(){slocCsv(_exportBase+'.csv',_dh,getDeltaExportRows());};
30305 window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
30306
30307 // ── Chart HTML report ─────────────────────────────────────────────────────
30308 function slocChartReport(fname, sd, dr) {
30309 var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
30310 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
30311 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
30312 function fmt(n){return Number(n).toLocaleString();}
30313 function px(n){return Math.round(n);}
30314 var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
30315 // Language map
30316 var lm={};
30317 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;});
30318 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
30319
30320 // Builds onmouse* attrs for interactive tooltip on each SVG element
30321 function barTT(label,val){
30322 return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
30323 }
30324
30325 // ── Chart 1: Baseline vs Current grouped bars (height fills the card to
30326 // match the Language Code Delta column height) ────────────
30327 var c1mets=[{l:'Code Lines',b:sd.bc,c:sd.cc,bc:'#E3A876',cc:'#C45C10'},{l:'Files Analyzed',b:sd.bf,c:sd.cf,bc:'#9FC3AE',cc:'#2A6846'},{l:'Comments',b:sd.bcm,c:sd.ccm,bc:'#E0C58A',cc:'#BE8A2E'}];
30328 var FONT_C="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif";
30329 var C1W=600,c1mt=36,c1mb=30,c1ml=14,c1mr=14,c1bw=56,c1gap=10,C1H=380;
30330 var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length;
30331 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30332 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"/>';}
30333 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
30334 c1mets.forEach(function(m,i){
30335 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
30336 // Per-metric scale so small magnitudes (files) stay visible next to large ones (code).
30337 var gMax=Math.max(m.b,m.c)*1.15||1;
30338 var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
30339 c1+='<text x="'+cx+'" y="16" text-anchor="middle" font-family="'+FONT_C+'" font-size="12" font-weight="600" fill="#444">'+esc(m.l)+'</text>';
30340 c1+='<rect class="cb" x="'+c1x0+'" y="'+px(c1mt+c1ph-bh0)+'" width="'+c1bw+'" height="'+px(bh0)+'" fill="'+m.bc+'" rx="5"'+barTT(m.l,'Baseline: '+fmt(m.b))+'/>';
30341 c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph-bh0-4)+'" text-anchor="middle" font-family="'+FONT_C+'" font-size="9" fill="'+m.bc+'">'+fmt(m.b)+'</text>';
30342 c1+='<rect class="cb" x="'+c1x1+'" y="'+px(c1mt+c1ph-bh1)+'" width="'+c1bw+'" height="'+px(bh1)+'" fill="'+m.cc+'" rx="5"'+barTT(m.l,'Current: '+fmt(m.c))+'/>';
30343 c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph-bh1-4)+'" text-anchor="middle" font-family="'+FONT_C+'" font-size="9" fill="'+m.cc+'">'+fmt(m.c)+'</text>';
30344 c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+(c1mt+c1ph+16)+'" text-anchor="middle" font-family="'+FONT_C+'" font-size="9" fill="#999">Before</text>';
30345 c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+(c1mt+c1ph+16)+'" text-anchor="middle" font-family="'+FONT_C+'" font-size="9" fill="'+m.cc+'">After</text>';
30346 });
30347 c1+='<text x="'+px(C1W/2)+'" y="'+(C1H-8)+'" text-anchor="middle" font-family="'+FONT_C+'" font-size="9" fill="#999">Each metric uses its own scale — compare Before vs After within a metric</text>';
30348 c1+='</svg>';
30349
30350 // ── Chart 2: Delta by Metric ─────────────────────────────────────────
30351 var mets=[{l:'Code Lines',v:sd.cc-sd.bc,mc:'#C45C10'},{l:'Files Analyzed',v:sd.cf-sd.bf,mc:'#2A6846'},{l:'Comment Lines',v:sd.ccm-sd.bcm,mc:'#BE8A2E'}];
30352 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
30353 var C2W=530,rH=56,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
30354 var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
30355 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30356 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30357 mets.forEach(function(m,i){
30358 var y=16+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
30359 var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
30360 var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
30361 c2+='<text x="'+(c2LW-8)+'" y="'+(y+20)+'" text-anchor="end" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="12" font-weight="600" fill="'+m.mc+'">'+esc(m.l)+'</text>';
30362 c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
30363 if(bw>=52){
30364 c2+='<text x="'+px(bx+bw/2)+'" y="'+(y+26)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="12" font-weight="700" fill="white">'+esc(vStr)+'</text>';
30365 }else{
30366 var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
30367 c2+='<text x="'+vx2+'" y="'+(y+26)+'" text-anchor="'+anc2+'" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="12" font-weight="700" fill="'+col+'">'+esc(vStr)+'</text>';
30368 }
30369 });
30370 c2+='</svg>';
30371
30372 // ── Chart 3: Language Code Delta ─────────────────────────────────────
30373 var c3='';
30374 if(langs.length){
30375 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
30376 var C3W=550,c3LW=124,c3FW=52;
30377 var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
30378 var L3rH=30,C3H=langs.length*L3rH+20;
30379 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30380 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30381 langs.forEach(function(l,i){
30382 var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
30383 var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
30384 var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
30385 c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="11" fill="#444">'+esc(l)+'</text>';
30386 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 \u2022 '+e.f+' file'+(e.f!==1?'s':''))+'/>';
30387 if(bw>=48){
30388 c3+='<text x="'+px(bx+bw/2)+'" y="'+(y+19)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="10" font-weight="700" fill="white">'+esc(vStr)+'</text>';
30389 }else{
30390 var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
30391 c3+='<text x="'+vx3+'" y="'+(y+19)+'" text-anchor="'+anc3+'" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="10" font-weight="700" fill="'+col+'">'+esc(vStr)+'</text>';
30392 }
30393 c3+='<text x="'+(C3W-5)+'" y="'+(y+19)+'" text-anchor="end" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="9" fill="#AAA">'+e.f+' file'+(e.f!==1?'s':'')+'</text>';
30394 });
30395 c3+='</svg>';
30396 }
30397
30398 // ── Chart 4: File Change Donut — centered pie with legend below
30399 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;});
30400 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
30401 var C4W=240,Ro=75,Ri=48,cx4=120,cy4=88,legY=172,legRowH=18,C4H=legY+Math.ceil(segs.length/2)*legRowH+8;
30402 var c4='<svg viewBox="0 0 '+C4W+' '+C4H+'" width="100%" style="max-width:336px;display:block;margin:0 auto;" xmlns="http://www.w3.org/2000/svg">';
30403 var ang=-Math.PI/2;
30404 segs.forEach(function(s){
30405 var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
30406 var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
30407 var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
30408 var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
30409 var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
30410 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 \u2022 '+px(s.v/tot*100)+'%')+'/>';
30411 ang+=sw;
30412 });
30413 c4+='<text x="'+cx4+'" y="'+(cy4-4)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="22" font-weight="bold" fill="#333">'+fmt(tot)+'</text>';
30414 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="10" fill="#888">total files</text>';
30415 segs.forEach(function(s,i){
30416 var col=i%2===0?14:C4W/2+6,row=Math.floor(i/2);
30417 c4+='<rect x="'+col+'" y="'+(legY+row*legRowH)+'" width="12" height="12" fill="'+s.c+'" rx="2"/>';
30418 c4+='<text x="'+(col+16)+'" y="'+(legY+row*legRowH+10)+'" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="11" fill="#555">'+esc(s.l)+': '+fmt(s.v)+'</text>';
30419 });
30420 c4+='</svg>';
30421
30422 // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
30423 var ttJs='var tt=document.getElementById("ox-tt");'+
30424 'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
30425 'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
30426 'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
30427 'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
30428 'tt.style.left=x+"px";tt.style.top=y+"px";}'+
30429 'function oxHT(){tt.style.display="none";}';
30430
30431 // body max-width keeps charts from inflating beyond design dimensions on
30432 // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
30433 // each chart's height blows up proportionally, breaking the one-page layout.
30434 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;}'+
30435 'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
30436 '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
30437 'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
30438 '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
30439 '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
30440 'svg{display:block;}'+
30441 '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
30442 '#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;}'+
30443 '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
30444 var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
30445 '<title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
30446 '<div id="ox-tt"><\/div>'+
30447 '<h1>OxideSLOC — Scan Delta Charts<\/h1>'+
30448 '<p class="sub">'+esc(proj)+' · '+esc(sd.bts)+' → '+esc(sd.cts)+'<\/p>'+
30449 '<div class="two-col">'+
30450 '<div class="card"><h2>Code Metrics — Baseline vs Current<\/h2>'+
30451 '<div class="leg">'+
30452 '<span><span class="dot" style="background:#E3A876"><\/span><span style="color:#C45C10;font-weight:600">Code Lines<\/span><\/span>'+
30453 '<span><span class="dot" style="background:#9FC3AE"><\/span><span style="color:#2A6846;font-weight:600">Files<\/span><\/span>'+
30454 '<span><span class="dot" style="background:#E0C58A"><\/span><span style="color:#BE8A2E;font-weight:600">Comments<\/span><\/span>'+
30455 '<span style="font-size:10px;color:#888"> (faded = before)<\/span><\/div>'+c1+'<\/div>'+
30456 (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
30457 '<\/div>'+
30458 '<div class="two-col">'+
30459 '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
30460 '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
30461 '<\/div>'+
30462 '<script>'+ttJs+'<\/script>'+
30463 '<\/body><\/html>';
30464 slocDownload(html, fname, 'text/html;charset=utf-8;');
30465 }
30466 window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
30467 window.buildDeltaChartsHtml = function() {
30468 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
30469 var sd=_sd;
30470 var projEl=document.querySelector('[data-folder]');
30471 var proj=projEl?projEl.getAttribute('data-folder'):'';
30472 var c1h=document.getElementById('ic-c1')?document.getElementById('ic-c1').innerHTML:'';
30473 var c2h=document.getElementById('ic-c2')?document.getElementById('ic-c2').innerHTML:'';
30474 var c3h=document.getElementById('ic-c3')?document.getElementById('ic-c3').innerHTML:'';
30475 var c4h=document.getElementById('ic-c4')?document.getElementById('ic-c4').innerHTML:'';
30476 var ttJs='var tt=document.getElementById("ox-tt");function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;tt.style.left=x+"px";tt.style.top=y+"px";}function oxHT(){tt.style.display="none";}';
30477 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;}h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}svg{display:block;}.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}#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;max-width:240px;white-space:nowrap;}.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
30478 return '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
30479 '<div id="ox-tt"><\/div>'+
30480 '<h1>OxideSLOC \u2014 Scan Delta Charts<\/h1>'+
30481 '<p class="sub">'+esc(proj)+' · '+esc(sd.bts||'')+' \u2192 '+esc(sd.cts||'')+'<\/p>'+
30482 '<div class="two-col">'+
30483 '<div class="card"><h2>Code Metrics \u2014 Baseline vs Current<\/h2>'+
30484 '<div class="leg"><span><span class="dot" style="background:#E3A876"><\/span><span style="color:#C45C10;font-weight:600">Code Lines<\/span><\/span>'+
30485 '<span><span class="dot" style="background:#9FC3AE"><\/span><span style="color:#2A6846;font-weight:600">Files<\/span><\/span>'+
30486 '<span><span class="dot" style="background:#E0C58A"><\/span><span style="color:#BE8A2E;font-weight:600">Comments<\/span><\/span><\/div>'+c1h+'<\/div>'+
30487 (c3h?'<div class="card"><h2>Language Code Delta<\/h2>'+c3h+'<\/div>':'<div><\/div>')+
30488 '<\/div>'+
30489 '<div class="two-col">'+
30490 '<div class="card"><h2>Delta by Metric<\/h2>'+c2h+'<\/div>'+
30491 '<div class="card"><h2>File Change Distribution<\/h2>'+c4h+'<\/div>'+
30492 '<\/div>'+
30493 '<script>'+ttJs+'<\/script>'+
30494 '<\/body><\/html>';
30495 };
30496 // ── Inline delta charts ────────────────────────────────────────────────────
30497 var _icTT=document.getElementById('ic-tt');
30498 window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
30499 window.icMT=function(e){if(!_icTT)return;var x=e.clientX+16,y=e.clientY-10,r=_icTT.getBoundingClientRect();if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;_icTT.style.left=x+'px';_icTT.style.top=y+'px';};
30500 window.icHT=function(){if(_icTT)_icTT.style.display='none';};
30501 window.addEventListener('blur',function(){window.icHT();});
30502 document.addEventListener('visibilitychange',function(){if(document.hidden)window.icHT();});
30503 (function(){
30504 // Theme-aware palette — matches the canonical scheme used by /test-metrics
30505 // charts so every page renders bars/text/grid with the same colours and
30506 // adapts to dark mode (see Design section in CLAUDE.md).
30507 var cs=getComputedStyle(document.body),dark=document.body.classList.contains('dark-theme');
30508 function cv(n,fb){var v=cs.getPropertyValue(n);return(v&&v.trim())||fb;}
30509 var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
30510 // Deeper shade of each metric hue for "before"/baseline bars — bold (not
30511 // washed) so the chart reads with the same weight as /test-metrics.
30512 var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
30513 var FADE=dark?'#524238':'#e6d0bf';
30514 var textCol=cv('--text','#43342d'),mutedCol=cv('--muted','#7b675b'),LGY=cv('--line','#e6d0bf'),axisCol=cv('--line-strong','#d8bfad'),surfCol=cv('--surface','#fbf7f2');
30515 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
30516 function fmt(n){return Number(n).toLocaleString();}
30517 function px(n){return Math.round(n);}
30518 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
30519 function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
30520 function addTT(el){if(!el)return;el.addEventListener('mouseover',function(e){var t=e.target.closest('[data-ttl]');if(t){var ttl=t.getAttribute('data-ttl');icTT(e,ttl,t.getAttribute('data-ttv'));el.querySelectorAll('[data-ttl]').forEach(function(x){x.style.filter='';x.style.opacity='';});el.querySelectorAll('[data-ttl]').forEach(function(x){if(x.getAttribute('data-ttl')===ttl)x.style.filter='brightness(1.2)';});}else{icHT();el.querySelectorAll('[data-ttl]').forEach(function(x){x.style.filter='';x.style.opacity='';})}});el.addEventListener('mouseleave',function(){icHT();el.querySelectorAll('[data-ttl]').forEach(function(x){x.style.filter='';x.style.opacity='';});});el.addEventListener('mousemove',function(e){icMT(e);});}
30521 var dr=getDeltaExportRows(),sd=_sd,lm={};
30522 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;});
30523 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
30524 // Chart 1: Baseline vs Current grouped bars. Height grows to fill the card so
30525 // the bars are as tall as the (usually taller) Language Code Delta sibling that
30526 // shares the same grid row, instead of sitting short at the top.
30527 var c1mets=[{l:'Code Lines',b:sd.bc,c:sd.cc,bc:OXD,cc:OX},{l:'Files Analyzed',b:sd.bf,c:sd.cf,bc:GND,cc:GN},{l:'Comments',b:sd.bcm,c:sd.ccm,bc:GDD,cc:GD}];
30528 function drawC1(){
30529 var C1W=600,C1H=188;
30530 var host=document.getElementById('ic-c1'),card=host?host.closest('.ic-card'):null;
30531 if(host&&card&&host.clientWidth>0){
30532 var avW=host.clientWidth;
30533 var availPx=(card.getBoundingClientRect().bottom-16)-host.getBoundingClientRect().top;
30534 var wantH=availPx*C1W/avW;
30535 if(wantH>C1H)C1H=wantH;
30536 }
30537 var c1mt=36,c1mb=44,c1ml=14,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=56,c1gap=10;
30538 var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30539 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"/>';}
30540 c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
30541 c1mets.forEach(function(m,i){
30542 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
30543 // Each metric scales to its OWN max so wildly different magnitudes (e.g. 4.5M
30544 // code lines vs 28K files) are all readable — a shared scale buries the small ones.
30545 var gMax=Math.max(m.b,m.c)*1.15||1;
30546 var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
30547 c1+='<text x="'+cx+'" y="16" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="12" font-weight="600" fill="'+textCol+'">'+esc(m.l)+'</text>';
30548 c1+='<rect'+btt(m.l,'Baseline: '+fmt(m.b))+' x="'+c1x0+'" y="'+px(c1mt+c1ph-bh0)+'" width="'+c1bw+'" height="'+px(bh0)+'" fill="'+m.bc+'" rx="3"/>';
30549 c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph-bh0-4)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="9" fill="'+mutedCol+'">'+fmt(m.b)+'</text>';
30550 c1+='<rect'+btt(m.l,'Current: '+fmt(m.c))+' x="'+c1x1+'" y="'+px(c1mt+c1ph-bh1)+'" width="'+c1bw+'" height="'+px(bh1)+'" fill="'+m.cc+'" rx="3"/>';
30551 c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph-bh1-4)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="9" fill="'+m.cc+'">'+fmt(m.c)+'</text>';
30552 c1+='<text x="'+px(c1x0+c1bw/2)+'" y="'+px(c1mt+c1ph+16)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="9" fill="'+mutedCol+'">Before</text>';
30553 c1+='<text x="'+px(c1x1+c1bw/2)+'" y="'+px(c1mt+c1ph+16)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="9" fill="'+m.cc+'">After</text>';
30554 });
30555 c1+='<text x="'+px(C1W/2)+'" y="'+px(C1H-6)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="8.5" fill="'+mutedCol+'">Each metric uses its own scale — compare Before vs After within a metric</text>';
30556 c1+='</svg>';
30557 return c1;
30558 }
30559 var c1=drawC1();
30560 // Chart 2: Delta by Metric
30561 var mets=[{l:'Code Lines',v:sd.cc-sd.bc,mc:OX},{l:'Files Analyzed',v:sd.cf-sd.bf,mc:GN},{l:'Comment Lines',v:sd.ccm-sd.bcm,mc:GD}];
30562 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
30563 var C2W=530,rH=56,C2H=mets.length*rH+28,c2LW=144,c2RP=18,cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
30564 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30565 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30566 mets.forEach(function(m,i){
30567 var y=16+i*rH,bw=(m.v===0?0:Math.max(Math.abs(m.v)/maxD*maxBW,2)),col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw,sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
30568 c2+='<text x="'+(c2LW-8)+'" y="'+(y+20)+'" text-anchor="end" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="12" font-weight="600" fill="'+textCol+'">'+esc(m.l)+'</text>';
30569 c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"/>';
30570 if(bw>=52){c2+='<text x="'+px(bx+bw/2)+'" y="'+(y+26)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="12" font-weight="700" fill="white">'+esc(vStr)+'</text>';}
30571 else{var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';c2+='<text x="'+vx2+'" y="'+(y+26)+'" text-anchor="'+anc2+'" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="12" font-weight="700" fill="'+textCol+'">'+esc(vStr)+'</text>';}
30572 });
30573 c2+='</svg>';
30574 // Chart 3: Language Code Delta
30575 var c3='';
30576 if(langs.length){
30577 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
30578 var C3W=550,c3LW=124,c3FW=52,cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4,L3rH=30,C3H=langs.length*L3rH+20;
30579 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30580 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30581 langs.forEach(function(l,i){
30582 var e=lm[l],y=8+i*L3rH,bw=(e.d===0?0:Math.max(Math.abs(e.d)/maxLD*maxLBW,2)),col=e.d>=0?GN:RD,vcol=(e.d===0?textCol:col),bx=e.d>=0?cx3:cx3-bw,sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
30583 c3+='<text x="'+(c3LW-7)+'" y="'+(y+18)+'" text-anchor="end" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="11" fill="'+textCol+'">'+esc(l)+'</text>';
30584 c3+='<rect'+btt(l,'Delta: '+vStr+' code lines \u2022 '+e.f+' file'+(e.f!==1?'s':''))+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="20" fill="'+col+'" rx="3"/>';
30585 if(bw>=48){c3+='<text x="'+px(bx+bw/2)+'" y="'+(y+19)+'" text-anchor="middle" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="10" font-weight="700" fill="white">'+esc(vStr)+'</text>';}
30586 else{var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';c3+='<text x="'+vx3+'" y="'+(y+19)+'" text-anchor="'+anc3+'" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="10" font-weight="700" fill="'+vcol+'">'+esc(vStr)+'</text>';}
30587 c3+='<text x="'+(C3W-5)+'" y="'+(y+19)+'" text-anchor="end" font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="9" fill="'+mutedCol+'">'+e.f+' file'+(e.f!==1?'s':'')+'</text>';
30588 });
30589 c3+='</svg>';
30590 }
30591 // Chart 4: File Change Donut — pie left, legend to the right (vertically centered)
30592 var FONT4='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
30593 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:FADE}].filter(function(s){return s.v>0;});
30594 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
30595 var DW=395,DH=Math.max(200,segs.length*30+44),cx4=104,cy4=Math.round(DH/2),Ro=88,Ri=48;
30596 var legX=212,legCount=segs.length,legSpacing=Math.max(18,Math.min(30,Math.floor((DH-24)/Math.max(legCount,1)))),legYStart=Math.round((DH-legCount*legSpacing)/2);
30597 var c4='<svg viewBox="0 0 '+DW+' '+DH+'" width="100%" style="display:block;max-width:480px;margin:0 auto;" xmlns="http://www.w3.org/2000/svg">',ang=-Math.PI/2;
30598 if(segs.length===1){
30599 var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
30600 c4+='<circle'+btt(segs[0].l,fmt(segs[0].v)+' files \u2022 100%')+' cx="'+cx4+'" cy="'+cy4+'" r="'+rm+'" fill="none" stroke="'+segs[0].c+'" stroke-width="'+rsw+'"/>';
30601 } else {
30602 // Give every visible slice a small minimum sweep, taken from the largest
30603 // slice. Without this a ~100% slice (e.g. all-Unchanged) spans a full 360°
30604 // arc whose start and end points coincide, so SVG renders nothing (blank).
30605 var TWO=2*Math.PI,minSw=0.06,raw=segs.map(function(s){return s.v/tot*TWO;}),maxIdx=0;
30606 for(var k=1;k<raw.length;k++){if(raw[k]>raw[maxIdx])maxIdx=k;}
30607 var deficit=0,sweeps=raw.map(function(rw,k){if(k!==maxIdx&&rw<minSw){deficit+=(minSw-rw);return minSw;}return rw;});
30608 sweeps[maxIdx]=Math.max(0.001,sweeps[maxIdx]-deficit);
30609 segs.forEach(function(s,si){
30610 var sw=Math.min(sweeps[si],TWO-0.06),a2=ang+sw;
30611 var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang),x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
30612 var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2),xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
30613 var pct=Math.round(s.v/tot*100);
30614 c4+='<path'+btt(s.l,fmt(s.v)+' files \u2022 '+pct+'%')+' 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="'+surfCol+'" stroke-width="2"/>';
30615 if(pct>=5){var mAng=ang+sw/2,mR=(Ro+Ri)/2;c4+='<text x="'+px(cx4+mR*Math.cos(mAng))+'" y="'+px(cy4+mR*Math.sin(mAng))+'" text-anchor="middle" dominant-baseline="middle" font-family="'+FONT4+'" font-size="11" font-weight="700" fill="'+(s.c===FADE?textCol:'#fff')+'" style="pointer-events:none;">'+pct+'%</text>';}
30616 ang+=sw;
30617 });
30618 }
30619 c4+='<text x="'+cx4+'" y="'+(cy4-7)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="21" font-weight="800" fill="'+textCol+'">'+fmt(tot)+'</text>';
30620 c4+='<text x="'+cx4+'" y="'+(cy4+14)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="11" fill="'+mutedCol+'">total files</text>';
30621 segs.forEach(function(s,i){
30622 var ly=legYStart+i*legSpacing,pct=Math.round(s.v/tot*100);
30623 c4+='<g'+btt(s.l,fmt(s.v)+' files \u2022 '+pct+'%')+' style="cursor:pointer;">';
30624 c4+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+legSpacing+'" fill="transparent"/>';
30625 c4+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+s.c+'"/>';
30626 c4+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT4+'" font-size="'+Math.min(13,legSpacing-3)+'" fill="'+textCol+'">'+esc(s.l)+'</text>';
30627 c4+='<text x="'+(legX+92)+'" y="'+(ly+10)+'" font-family="'+FONT4+'" font-size="'+Math.min(12,legSpacing-4)+'" font-weight="700" fill="'+mutedCol+'">'+fmt(s.v)+' ('+pct+'%)</text>';
30628 c4+='</g>';
30629 });
30630 c4+='</svg>';
30631 // Inject the fixed-height siblings first so the grid row settles to the (taller)
30632 // Language Code Delta height, then draw Code Metrics (c1) to fill that height.
30633 var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
30634 var e3=document.getElementById('ic-c3');if(e3){e3.innerHTML=langs.length?c3:'<p style="color:var(--muted);font-size:13px;padding:8px 0 0;">No language delta.</p>';addTT(e3);}
30635 var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
30636 var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
30637 var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=drawC1();addTT(e1);}
30638
30639 // Compare Timeline chart (Baseline vs Current, 2 points)
30640 (function() {
30641 var activeCmpMetric='code';
30642 var cmpMetricLabel={code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'};
30643 function renderCmpTL(metric, targetSvg, targetH) {
30644 var svg=targetSvg||document.getElementById('cmp-tl-svg');if(!svg)return;
30645 var W=svg.getBoundingClientRect().width||800,H=targetH||280;
30646 svg.setAttribute('height',H);
30647 var pad={l:62,r:20,t:32,b:72};
30648 var dark=document.body.classList.contains('dark-theme');
30649 var cmpPts=[
30650 {v:{code:_sd.bc,files:_sd.bf,comments:_sd.bcm,tests:_sd.btests,cov:_sd.bcov},label:(_sd.bsha||'').substring(0,7)||'Base'},
30651 {v:{code:_sd.cc,files:_sd.cf,comments:_sd.ccm,tests:_sd.ctests,cov:_sd.ccov},label:(_sd.csha||'').substring(0,7)||'Curr'}
30652 ];
30653 var pts=cmpPts.map(function(p){var v=p.v[metric];return(v==null)?null:Number(v);});
30654 var valid=pts.filter(function(v){return v!=null;});
30655 if(!valid.length){var _nd_dark=document.body.classList.contains('dark-theme');var _nd_bg=_nd_dark?'#241a12':'#fbf7f2';var _nd_tc=_nd_dark?'rgba(255,255,255,0.30)':'rgba(67,52,45,0.32)';var _nd_ts=_nd_dark?'rgba(255,255,255,0.55)':'rgba(67,52,45,0.60)';var _nd_lbl=(cmpMetricLabel[metric]||metric);var _nd_cov=metric==='cov';var _nd_msg=_nd_cov?'No coverage data for these scans':'No '+_nd_lbl.toLowerCase()+' recorded';var _nd_sub=_nd_cov?'Coverage appears once test results are captured during a scan.':'Neither the baseline nor current scan reported a value for this metric.';var _cx=W/2,_cy=H/2;svg.setAttribute('viewBox','0 0 '+W+' '+H);svg.innerHTML='<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+_nd_bg+'" rx="8"/>'+'<g opacity="0.55"><rect x="'+(_cx-28).toFixed(1)+'" y="'+(_cy-50).toFixed(1)+'" width="56" height="34" rx="5" fill="none" stroke="'+_nd_tc+'" stroke-width="1.6"/><polyline points="'+(_cx-20).toFixed(1)+','+(_cy-24).toFixed(1)+' '+(_cx-7).toFixed(1)+','+(_cy-30).toFixed(1)+' '+(_cx+6).toFixed(1)+','+(_cy-26).toFixed(1)+' '+(_cx+20).toFixed(1)+','+(_cy-34).toFixed(1)+'" fill="none" stroke="'+_nd_tc+'" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></g>'+'<text x="'+_cx.toFixed(1)+'" y="'+(_cy+4).toFixed(1)+'" text-anchor="middle" font-size="14" font-weight="700" fill="'+_nd_ts+'">'+_nd_msg+'</text>'+'<text x="'+_cx.toFixed(1)+'" y="'+(_cy+24).toFixed(1)+'" text-anchor="middle" font-size="11.5" fill="'+_nd_tc+'">'+_nd_sub+'</text>';return;}
30656 var minV=0,maxV=Math.max.apply(null,valid);
30657 if(maxV<=0){maxV=1;}else{maxV=maxV*1.08;}
30658 var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
30659 var cx0=pad.l,cx1=pad.l+plotW;
30660 var cy0=pts[0]!=null?pad.t+plotH-(pts[0]-minV)/(maxV-minV)*plotH:pad.t+plotH;
30661 var cy1=pts[1]!=null?pad.t+plotH-(pts[1]-minV)/(maxV-minV)*plotH:pad.t+plotH;
30662 var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
30663 var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
30664 var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
30665 function fmtN(n){var v=Number(n),a=Math.abs(v);if(a>=1e6)return(v/1e6).toFixed(1).replace(/\.0$/,'')+'M';if(a>=1e4)return(v/1e3).toFixed(1).replace(/\.0$/,'')+'K';return v.toLocaleString();}
30666 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
30667 var parts=[];
30668 parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
30669 for(var gi=0;gi<5;gi++){
30670 var gy=pad.t+plotH/4*gi,gv=maxV-(maxV-minV)/4*gi;
30671 parts.push('<line x1="'+pad.l+'" y1="'+gy.toFixed(1)+'" x2="'+(W-pad.r)+'" y2="'+gy.toFixed(1)+'" stroke="'+gridColor+'" stroke-width="1"/>');
30672 parts.push('<text x="'+(pad.l-6)+'" y="'+(gy+4).toFixed(1)+'" text-anchor="end" font-size="10" fill="'+textColor+'">'+fmtN(gv)+'</text>');
30673 }
30674 parts.push('<path d="M '+cx0.toFixed(1)+' '+(pad.t+plotH)+' L '+cx0.toFixed(1)+' '+cy0.toFixed(1)+' L '+cx1.toFixed(1)+' '+cy1.toFixed(1)+' L '+cx1.toFixed(1)+' '+(pad.t+plotH)+' Z" fill="'+areaColor+'"/>');
30675 parts.push('<line x1="'+cx0.toFixed(1)+'" y1="'+cy0.toFixed(1)+'" x2="'+cx1.toFixed(1)+'" y2="'+cy1.toFixed(1)+'" stroke="#d37a4c" stroke-width="2.2"/>');
30676 var dotPts=[{cx:cx0,cy:cy0,v:pts[0],lbl:cmpPts[0].label,anchor:'start',lbl2:'BASELINE'},
30677 {cx:cx1,cy:cy1,v:pts[1],lbl:cmpPts[1].label,anchor:'end',lbl2:'CURRENT'}];
30678 dotPts.forEach(function(pt){
30679 parts.push('<text x="'+pt.cx.toFixed(1)+'" y="'+(pt.cy-11).toFixed(1)+'" text-anchor="'+pt.anchor+'" font-size="11" font-weight="600" fill="'+textColor+'">'+Number(pt.v).toLocaleString()+'</text>');
30680 parts.push('<circle cx="'+pt.cx.toFixed(1)+'" cy="'+pt.cy.toFixed(1)+'" r="5" fill="#d37a4c" stroke="'+(dark?'#241a12':'#fbf7f2')+'" stroke-width="1.5"/>');
30681 parts.push('<text x="'+pt.cx.toFixed(1)+'" y="'+(H-pad.b+18)+'" text-anchor="'+pt.anchor+'" font-size="15" fill="'+textColor+'" font-family="ui-monospace,monospace">'+escH(pt.lbl)+'</text>');
30682 parts.push('<text x="'+pt.cx.toFixed(1)+'" y="'+(H-pad.b+32)+'" text-anchor="'+pt.anchor+'" font-size="9" font-weight="700" fill="'+textColor+'">'+escH(pt.lbl2)+'</text>');
30683 });
30684 parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escH(cmpMetricLabel[metric]||metric)+'</text>');
30685 svg.setAttribute('viewBox','0 0 '+W+' '+H);
30686 svg.innerHTML=parts.join('');
30687 // Hover: crosshair + tooltip (matches multi-scan timeline)
30688 var cmpTT=document.getElementById('ic-tt');
30689 svg.onmousemove=function(e){
30690 var rect=svg.getBoundingClientRect();
30691 var scaleX=W/rect.width;
30692 var mouseX=(e.clientX-rect.left)*scaleX;
30693 var nearest=-1,minDist=Infinity;
30694 var cxArr=[cx0,cx1];
30695 for(var k=0;k<2;k++){if(pts[k]==null)continue;var dx=Math.abs(cxArr[k]-mouseX);if(dx<minDist){minDist=dx;nearest=k;}}
30696 if(nearest<0)return;
30697 var nc=cxArr[nearest],ny=(nearest===0?cy0:cy1);
30698 var xhair=svg.querySelector('.cmp-xhair');
30699 if(!xhair){xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','cmp-xhair');svg.appendChild(xhair);}
30700 xhair.innerHTML='<line x1="'+nc.toFixed(1)+'" y1="'+pad.t+'" x2="'+nc.toFixed(1)+'" y2="'+(pad.t+plotH)+'" stroke="rgba(211,122,76,0.55)" stroke-width="1.5" stroke-dasharray="4,3" pointer-events="none"/>';
30701 if(!cmpTT)return;
30702 var clbl=cmpPts[nearest].label;
30703 var scanLbl=nearest===0?'Baseline':'Current';
30704 cmpTT.innerHTML='<strong>'+scanLbl+'</strong> <span style="font-family:monospace;font-size:11px;opacity:.75">'+escH(clbl)+'</span><br>'+escH(cmpMetricLabel[metric]||metric)+': <strong>'+Number(pts[nearest]).toLocaleString()+'</strong>';
30705 var bx=rect.left+(nc/W*rect.width)+18;
30706 if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
30707 cmpTT.style.left=bx+'px';cmpTT.style.top=(e.clientY-38)+'px';cmpTT.style.display='block';
30708 };
30709 svg.onmouseleave=function(){
30710 var xhair=svg.querySelector('.cmp-xhair');if(xhair)xhair.innerHTML='';
30711 if(cmpTT)cmpTT.style.display='none';
30712 };
30713 }
30714 document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(btn){
30715 btn.addEventListener('click',function(){
30716 activeCmpMetric=this.dataset.cmpMetric;
30717 document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(b){b.classList.remove('active');});
30718 this.classList.add('active');
30719 renderCmpTL(activeCmpMetric);
30720 });
30721 });
30722 var ttgl=document.getElementById('theme-toggle');
30723 if(ttgl)ttgl.addEventListener('click',function(){setTimeout(function(){renderCmpTL(activeCmpMetric);if(window.__sdFvTL)renderCmpTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);},0);});
30724 if(typeof ResizeObserver!=='undefined'){
30725 var cmpSvg=document.getElementById('cmp-tl-svg');
30726 if(cmpSvg)new ResizeObserver(function(){renderCmpTL(activeCmpMetric);}).observe(cmpSvg);
30727 }
30728 // Expose the timeline renderer + current metric so the Full View modal can
30729 // re-draw it live (pixel-sized chart can't be snapshot-scaled like the bars).
30730 window.__sdRenderTL=function(m,svgEl,h){renderCmpTL(m,svgEl,h);};
30731 window.__sdGetMetric=function(){return activeCmpMetric;};
30732 renderCmpTL(activeCmpMetric);
30733 })();
30734
30735 // HTML legend hover -> highlight matching SVG bars within the SAME card only
30736 document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){
30737 var metric=leg.getAttribute('data-highlight');
30738 var parentCard=leg.closest('.ic-card');
30739 var chartEl=parentCard?parentCard.querySelector('[id]'):null;
30740 if(!chartEl)return;
30741 leg.addEventListener('mouseenter',function(){
30742 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){
30743 if(x.getAttribute('data-ttl').indexOf(metric)===0){x.style.filter='brightness(1.35) drop-shadow(0 2px 8px rgba(0,0,0,0.28))';x.style.opacity='1';}
30744 else{x.style.opacity='0.28';}
30745 });
30746 });
30747 leg.addEventListener('mouseleave',function(){
30748 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){x.style.filter='';x.style.opacity='';});
30749 });
30750 });
30751
30752 // ── Full View: enlarge any chart in a modal (snapshots current SVG) ──────
30753 (function(){
30754 var ov=document.getElementById('ic-svg-modal-ov');
30755 var body=document.getElementById('ic-svg-modal-body');
30756 var ttl=document.getElementById('ic-svg-modal-title');
30757 var closeBtn=document.getElementById('ic-svg-modal-close');
30758 if(!ov||!body)return;
30759 function close(){
30760 ov.classList.remove('open');body.innerHTML='';
30761 if(window.__sdFvTL){if(window.__sdFvTL.ro)window.__sdFvTL.ro.disconnect();window.__sdFvTL=null;}
30762 var tt=document.getElementById('ic-tt');if(tt)tt.style.display='none';
30763 }
30764 function open(srcId,title){
30765 var src=document.getElementById(srcId);if(!src)return;
30766 if(ttl)ttl.textContent=title||'';
30767 // The Timeline is pixel-sized (viewBox locked to its render width), so a static
30768 // snapshot stretches and loses interactivity. Re-render it live into the modal at
30769 // full size instead — keeps proportions, animation, crosshair, tooltip and the
30770 // metric tabs working exactly like the inline chart.
30771 if(srcId==='cmp-tl-svg'&&window.__sdRenderTL){
30772 var curM=window.__sdGetMetric?window.__sdGetMetric():'code';
30773 var mets=[['code','Code Lines'],['files','Files'],['comments','Comments'],['tests','Tests'],['cov','Coverage']];
30774 var btnsHtml=mets.map(function(p){return '<button class="chart-metric-btn'+(p[0]===curM?' active':'')+'" data-fv-metric="'+p[0]+'">'+p[1]+'</button>';}).join('');
30775 body.innerHTML='<div class="cmp-tl-btns" style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px;">'+btnsHtml+'</div><div class="chart-wrap" style="width:100%;"><svg id="cmp-tl-fv-svg" width="100%" height="440" style="display:block;width:100%;"></svg></div>';
30776 var fvSvg=body.querySelector('#cmp-tl-fv-svg');
30777 window.__sdFvTL={svg:fvSvg,h:440,metric:curM,ro:null};
30778 ov.classList.add('open');
30779 requestAnimationFrame(function(){window.__sdRenderTL(window.__sdFvTL.metric,fvSvg,440);});
30780 if(typeof ResizeObserver!=='undefined'){var ro=new ResizeObserver(function(){if(window.__sdFvTL)window.__sdRenderTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);});ro.observe(fvSvg);window.__sdFvTL.ro=ro;}
30781 body.querySelectorAll('[data-fv-metric]').forEach(function(b){
30782 b.addEventListener('click',function(){
30783 if(!window.__sdFvTL)return;
30784 window.__sdFvTL.metric=this.getAttribute('data-fv-metric');
30785 body.querySelectorAll('[data-fv-metric]').forEach(function(x){x.classList.remove('active');});
30786 this.classList.add('active');
30787 window.__sdRenderTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);
30788 });
30789 });
30790 return;
30791 }
30792 var card=src.closest('.ic-card');
30793 var legHtml='';
30794 if(card){var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg" style="margin-bottom:14px;">'+leg.innerHTML+'</div>';}
30795 var inner=src.tagName.toLowerCase()==='svg'?src.outerHTML:src.innerHTML;
30796 if(!inner||!inner.replace(/\s/g,'')){body.innerHTML=legHtml+'<p style="color:var(--muted);font-size:13px;padding:8px 0 0;">No chart data to display.</p>';ov.classList.add('open');return;}
30797 body.innerHTML=legHtml+inner;
30798 var svg=body.querySelector('svg');
30799 if(svg){svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}
30800 addTT(body);
30801 ov.classList.add('open');
30802 }
30803 document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){
30804 btn.addEventListener('click',function(){open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));});
30805 });
30806 if(closeBtn)closeBtn.addEventListener('click',close);
30807 ov.addEventListener('click',function(e){if(e.target===ov)close();});
30808 document.addEventListener('keydown',function(e){if(e.key==='Escape'&&ov.classList.contains('open'))close();});
30809 })();
30810
30811 document.querySelectorAll('.cmp-author-val').forEach(function(el){var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');});
30812 })();
30813 </script>
30814 {{ toast_assets|safe }}
30815 <script nonce="{{ csp_nonce }}">
30816 (function(){
30817 var S=[{n:'Classic',a:'#b85d33',b:'#7a371b'},{n:'Navy',a:'#283790',b:'#1e1e24'},{n:'Ember',a:'#ce5d3d',b:'#1e1e24'},{n:'Ocean',a:'#1f439b',b:'#1e1e24'},{n:'Royal',a:'#003184',b:'#1e1e24'}];
30818 function ap(s){document.documentElement.style.setProperty('--nav',s.a);document.documentElement.style.setProperty('--nav-2',s.b);try{localStorage.setItem('sloc-ns',JSON.stringify(s));}catch(e){}document.querySelectorAll('.scheme-swatch').forEach(function(x){x.classList.toggle('active',x.dataset.n===s.n);});}
30819 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
30820 function init(){
30821 var btn=document.getElementById('settings-btn');if(!btn)return;
30822 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
30823 m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
30824 document.body.appendChild(m);
30825 var g=document.getElementById('scheme-grid');
30826 if(g)S.forEach(function(s){var el=document.createElement('button');el.type='button';el.className='scheme-swatch';el.dataset.n=s.n;el.title=s.n;var p=document.createElement('div');p.className='scheme-preview';p.style.background='linear-gradient(135deg,'+s.a+','+s.b+')';var l=document.createElement('span');l.className='scheme-label';l.textContent=s.n;el.appendChild(p);el.appendChild(l);try{var c=JSON.parse(localStorage.getItem('sloc-ns'));if(c&&c.n===s.n)el.classList.add('active');}catch(e){}el.addEventListener('click',function(){ap(s);});g.appendChild(el);});
30827 var cl=document.getElementById('settings-close');
30828 window.tzAbbr=function(z){return{'America/Los_Angeles':'PT','America/Denver':'MT','America/Chicago':'CT','America/New_York':'ET','America/Anchorage':'AT','Pacific/Honolulu':'HT'}[z]||'PT';};window.tzCity=function(z){return{'America/Los_Angeles':'Los Angeles','America/Denver':'Denver','America/Chicago':'Chicago','America/New_York':'New York','America/Anchorage':'Anchorage','Pacific/Honolulu':'Honolulu'}[z]||'';};window.tzOffset=function(z){var r='';try{var p=new Intl.DateTimeFormat('en-US',{timeZone:z,timeZoneName:'longOffset'}).formatToParts(new Date());p.forEach(function(x){if(x.type==='timeZoneName')r=x.value.replace('GMT','UTC');});}catch(e){}return r;};window.tf24=function(){try{return localStorage.getItem('sloc-tf')!=='12';}catch(e){return true;}};window.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';var h24=window.tf24();try{var pts=new Intl.DateTimeFormat('en-US',{timeZone:tz,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:!h24}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});var t=v.hour+':'+v.minute;if(!h24&&v.dayPeriod)t+=' '+v.dayPeriod;return v.year+'-'+v.month+'-'+v.day+' '+t+' '+window.tzAbbr(tz);}catch(e){return'';}};window.enhanceTzOptions=function(sel){if(!sel)return;Array.prototype.forEach.call(sel.options,function(o){var base=o.textContent.split(' - ')[0];var city=window.tzCity(o.value);var off=window.tzOffset(o.value);o.textContent=base+(city?' - '+city:'')+(off?' - '+off:'');});};window.applyTz=function(tz){try{localStorage.setItem('sloc-tz',tz);}catch(e){}document.querySelectorAll('[data-utc-ms]').forEach(function(el){var ms=parseInt(el.getAttribute('data-utc-ms'),10);if(!isNaN(ms))el.textContent=window.fmtTz(ms,tz);});};window.applyTf=function(tf){try{localStorage.setItem('sloc-tf',tf);}catch(e){}var z;try{z=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){z='America/Los_Angeles';}window.applyTz(z);};var tzSel=document.getElementById('tz-select');window.enhanceTzOptions(tzSel);var storedTz;try{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){storedTz='America/Los_Angeles';}if(tzSel){tzSel.value=storedTz;tzSel.addEventListener('change',function(){window.applyTz(this.value);});}window.applyTz(storedTz);(function(){var tzp=document.getElementById('tz-select');if(!tzp||document.getElementById('tf-select')||!tzp.parentNode)return;var tw=document.createElement('div');tw.style.marginTop='10px';var tl=document.createElement('div');tl.className='settings-modal-label';tl.style.marginBottom='8px';tl.textContent='Time format';var tfSel=document.createElement('select');tfSel.className='tz-select';tfSel.id='tf-select';tfSel.innerHTML='<option value="24">24-hour (14:30)</option><option value="12">12-hour (2:30 PM)</option>';tw.appendChild(tl);tw.appendChild(tfSel);tzp.parentNode.appendChild(tw);var storedTf;try{storedTf=localStorage.getItem('sloc-tf')||'24';}catch(e){storedTf='24';}tfSel.value=storedTf;tfSel.addEventListener('change',function(){window.applyTf(this.value);});})();
30829 btn.addEventListener('click',function(e){e.stopPropagation();var r=btn.getBoundingClientRect();m.style.top=(r.bottom+6)+'px';m.style.right=(window.innerWidth-r.right)+'px';m.classList.toggle('open');});
30830 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
30831 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
30832 }
30833 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
30834 }());
30835 </script>
30836 <script nonce="{{ csp_nonce }}">(function(){var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping'),lbl=document.getElementById('server-status-label'),fm=document.getElementById('footer-mode'),isServer=location.hostname!=='localhost'&&location.hostname!=='127.0.0.1'&&location.hostname!=='[::1]';
30837 if(location.protocol==='file:'){if(lbl)lbl.textContent='Offline';if(dot){dot.style.background='#888';dot.style.boxShadow='none';}if(pingEl)pingEl.textContent='';if(fm)fm.textContent='oxide-sloc v{{ version }} \u2014 Saved Report';var td=document.querySelector('.server-status-tip');if(td)td.textContent='Saved HTML report \u2014 server not connected.';return;}
30838 if(lbl)lbl.textContent=isServer?'Server':'Local';if(fm)fm.textContent='oxide-sloc v{{ version }} — Mode: '+(isServer?'Network Server':'Local');function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';dot.style.boxShadow='0 0 0 4px rgba(38,215,104,0.14)';}else if(ms<300){dot.style.background='#f5a623';dot.style.boxShadow='0 0 0 4px rgba(245,166,35,0.14)';}else{dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}}function doPing(){var t0=performance.now();fetch('/healthz',{cache:'no-store'}).then(function(){var ms=Math.round(performance.now()-t0);if(pingEl)pingEl.textContent=ms+'ms';if(tipEl)tipEl.textContent='Server latency: '+ms+' ms';setDot(ms);}).catch(function(){if(pingEl)pingEl.textContent='';if(tipEl)tipEl.textContent='';if(dot){dot.style.background='#e05c5c';dot.style.boxShadow='0 0 0 4px rgba(224,92,92,0.14)';}});}doPing();setInterval(doPing,5000);})();</script>
30839</body>
30840</html>
30841"##,
30842 ext = "html"
30843)]
30844#[allow(clippy::struct_excessive_bools)]
30846struct CompareTemplate {
30847 loading_overlay: String,
30849 version: &'static str,
30850 project_label: String,
30851 baseline_git_commit: String,
30852 current_git_commit: String,
30853 baseline_run_id: String,
30854 current_run_id: String,
30855 baseline_run_id_short: String,
30856 current_run_id_short: String,
30857 baseline_timestamp: String,
30858 baseline_timestamp_utc_ms: i64,
30859 current_timestamp: String,
30860 current_timestamp_utc_ms: i64,
30861 project_path: String,
30862 baseline_code: u64,
30863 current_code: u64,
30864 code_lines_delta_str: String,
30865 code_lines_delta_class: String,
30866 baseline_files: u64,
30867 current_files: u64,
30868 files_analyzed_delta_str: String,
30869 files_analyzed_delta_class: String,
30870 baseline_comments: u64,
30871 current_comments: u64,
30872 comment_lines_delta_str: String,
30873 comment_lines_delta_class: String,
30874 baseline_code_fmt: String,
30875 current_code_fmt: String,
30876 baseline_files_fmt: String,
30877 current_files_fmt: String,
30878 baseline_comments_fmt: String,
30879 current_comments_fmt: String,
30880 code_lines_pct_str: String,
30881 files_analyzed_pct_str: String,
30882 comment_lines_pct_str: String,
30883 code_lines_added: i64,
30884 code_lines_removed: i64,
30885 code_lines_modified: i64,
30887 code_lines_unmodified: i64,
30889 new_scope: bool,
30891 churn_rate_str: String,
30892 churn_rate_class: String,
30893 scope_flag: bool,
30894 files_added: usize,
30895 files_removed: usize,
30896 files_modified: usize,
30897 files_unchanged: usize,
30898 file_rows: Vec<CompareFileDeltaRow>,
30899 baseline_git_author: Option<String>,
30900 current_git_author: Option<String>,
30901 baseline_git_branch: String,
30902 current_git_branch: String,
30903 baseline_git_tags: Option<String>,
30904 current_git_tags: Option<String>,
30905 baseline_git_commit_date: Option<String>,
30906 current_git_commit_date: Option<String>,
30907 project_name: String,
30908 submodule_options: Vec<String>,
30910 has_any_submodule_data: bool,
30912 active_submodule: Option<String>,
30914 super_scope_active: bool,
30916 csp_nonce: String,
30917 toast_assets: String,
30919 coverage_delta_card: String,
30921 baseline_test_count: u64,
30922 current_test_count: u64,
30923 baseline_coverage_pct: Option<f64>,
30924 current_coverage_pct: Option<f64>,
30925}
30926
30927#[derive(Template)]
30930#[template(
30931 source = r##"
30932<!doctype html>
30933<html lang="en">
30934<head>
30935 <meta charset="utf-8">
30936 <meta name="viewport" content="width=device-width, initial-scale=1">
30937 <title>OxideSLOC | Sign In</title>
30938 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
30939 <style nonce="{{ csp_nonce }}">
30940 :root {
30941 --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
30942 --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
30943 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
30944 --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
30945 }
30946 *{box-sizing:border-box;}
30947 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);}
30948 .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);}
30949 .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
30950 .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
30951 .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
30952 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30953 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
30954 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30955 .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;}
30956 @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));}}
30957 .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
30958 .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
30959 h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
30960 .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
30961 .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;}
30962 label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
30963 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;}
30964 input[type=password]:focus{border-color:var(--oxide);}
30965 .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;}
30966 .btn:hover{opacity:.88;}
30967 .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
30968 code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
30969 </style>
30970</head>
30971<body>
30972 <div class="background-watermarks" aria-hidden="true">
30973 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30974 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30975 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30976 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30977 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30978 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30979 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30980 </div>
30981 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
30982<nav class="top-nav">
30983 <a class="brand" href="/">
30984 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
30985 <span class="brand-title">OxideSLOC</span>
30986 </a>
30987</nav>
30988<main class="page">
30989 <div class="card">
30990 <h1>Sign In</h1>
30991 <p class="subtitle">Enter the API key printed when the server started.</p>
30992 {% if has_error %}
30993 <div class="error">Incorrect API key — please try again.</div>
30994 {% endif %}
30995 <form method="POST" action="/auth/login">
30996 <input type="hidden" name="next" value="{{ next_url|e }}">
30997 <label for="key">API Key</label>
30998 <input id="key" type="password" name="key" autocomplete="current-password"
30999 placeholder="Paste your API key here" autofocus>
31000 <button type="submit" class="btn">Sign In</button>
31001 </form>
31002 <p class="hint">
31003 The API key was printed in the terminal when the server started.<br>
31004 To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
31005 Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
31006 </p>
31007 </div>
31008</main>
31009<script nonce="{{ csp_nonce }}">
31010(function() {
31011 (function randomizeWatermarks() {
31012 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
31013 if (!wms.length) return;
31014 var placed = [];
31015 function tooClose(top, left) {
31016 for (var i = 0; i < placed.length; i++) {
31017 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
31018 if (dt < 16 && dl < 12) return true;
31019 }
31020 return false;
31021 }
31022 function pick(leftBand) {
31023 for (var attempt = 0; attempt < 50; attempt++) {
31024 var top = Math.random() * 88 + 2;
31025 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
31026 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
31027 }
31028 var top = Math.random() * 88 + 2;
31029 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
31030 placed.push([top, left]); return [top, left];
31031 }
31032 var half = Math.floor(wms.length / 2);
31033 wms.forEach(function (img, i) {
31034 var pos = pick(i < half);
31035 var size = Math.floor(Math.random() * 100 + 120);
31036 var rot = (Math.random() * 360).toFixed(1);
31037 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
31038 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;
31039 });
31040 })();
31041 (function spawnCodeParticles() {
31042 var container = document.getElementById('code-particles');
31043 if (!container) return;
31044 var snippets = [
31045 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
31046 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
31047 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
31048 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
31049 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
31050 ];
31051 var count = 38;
31052 for (var i = 0; i < count; i++) {
31053 (function(idx) {
31054 var el = document.createElement('span');
31055 el.className = 'code-particle';
31056 el.textContent = snippets[idx % snippets.length];
31057 var left = Math.random() * 94 + 2;
31058 var top = Math.random() * 88 + 6;
31059 var dur = (Math.random() * 10 + 9).toFixed(1);
31060 var delay = (Math.random() * 18).toFixed(1);
31061 var rot = (Math.random() * 26 - 13).toFixed(1);
31062 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
31063 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
31064 container.appendChild(el);
31065 })(i);
31066 }
31067 })();
31068})();
31069</script>
31070</body>
31071</html>
31072"##,
31073 ext = "html"
31074)]
31075pub(crate) struct LoginTemplate {
31076 pub(crate) csp_nonce: String,
31077 pub(crate) has_error: bool,
31078 pub(crate) next_url: String,
31079 pub(crate) lockout_threshold: u32,
31080}
31081
31082#[derive(Template)]
31085#[template(
31086 source = r##"
31087<!doctype html>
31088<html lang="en">
31089<head>
31090 <meta charset="utf-8">
31091 <meta name="viewport" content="width=device-width, initial-scale=1">
31092 <title>OxideSLOC — REST API Reference</title>
31093 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
31094 <style nonce="{{ csp_nonce }}">
31095 :root {
31096 --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
31097 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
31098 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
31099 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
31100 --success:#16a34a;
31101 }
31102 body.dark-theme {
31103 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
31104 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
31105 }
31106 *{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);} body{display:flex;flex-direction:column;}
31107 .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);}
31108 .top-nav-inner{max-width:960px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;flex-wrap:nowrap;}
31109 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
31110 .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));}
31111 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
31112 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
31113 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
31114 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
31115 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
31116 @media (max-width: 1150px) { .nav-right { gap: 4px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 8px; font-size: 11px; min-height: 34px; } .brand-subtitle { display: none; } .server-online-pill { width: 34px; padding: 0; justify-content: center; font-size: 0; gap: 0; min-height: 34px; } }
31117 .nav-pill{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;white-space:nowrap;text-decoration:none;}
31118 a.nav-pill:hover{background:rgba(255,255,255,0.18);}
31119 .nav-pill.active{background:rgba(255,255,255,0.22);}
31120 .nav-dropdown{position:relative;display:inline-flex;}
31121 .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;white-space:nowrap;text-decoration:none;}
31122 .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
31123 .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;}
31124 .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;}
31125 .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);}
31126 .nav-dropdown-menu a:last-child{border-bottom:none;}
31127 .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
31128 .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
31129 .theme-toggle{width: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;min-height:38px;}
31130 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
31131 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
31132 .settings-modal{position:fixed;z-index:9999;background:var(--surface-2);border:1px solid var(--line-strong);border-radius:14px;box-shadow:0 12px 36px rgba(0,0,0,0.22);min-width:260px;max-width:320px;opacity:0;pointer-events:none;transform:translateY(-8px) scale(0.97);transition:opacity 0.18s ease,transform 0.18s ease;overflow:hidden;}
31133 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
31134 .settings-modal-header{display:flex;align-items:center;justify-content:space-between;padding:14px 16px 10px;border-bottom:1px solid var(--line);font-size:13px;font-weight:800;color:var(--text);}
31135 .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
31136 .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
31137 .settings-modal-body{padding:14px 16px 16px;}
31138 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
31139 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
31140 .scheme-swatch{display:flex;flex-direction:column;align-items:center;gap:5px;background:none;border:1.5px solid var(--line);border-radius:10px;cursor:pointer;padding:7px 4px 6px;transition:border-color 0.15s ease,transform 0.12s ease;}
31141 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
31142 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
31143 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
31144 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
31145 .tz-select{width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2);color:var(--text);font-size:12px;font-weight:600;cursor:pointer;outline:none;box-sizing:border-box;}
31146 .tz-select:focus{border-color:var(--oxide);}
31147 .page{max-width:960px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
31148 .page-header{margin-bottom:28px;}
31149 .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
31150 .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
31151 .callout{border-radius:12px;padding:16px 20px;margin-bottom:28px;display:flex;align-items:flex-start;gap:14px;font-size:14px;line-height:1.6;}
31152 .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
31153 .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
31154 .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
31155 .callout strong{font-weight:800;}
31156 .callout code{background:rgba(0,0,0,0.07);border-radius:4px;padding:1px 5px;font-size:12px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;}
31157 body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
31158 .base-url-bar{background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:12px 16px;margin-bottom:28px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
31159 .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
31160 .base-url-value{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;color:var(--accent-2);flex:1;word-break:break-all;}
31161 body.dark-theme .base-url-value{color:var(--accent);}
31162 .section{margin-bottom:36px;}
31163 .section-title{font-size:18px;font-weight:850;letter-spacing:-0.02em;margin:0 0 14px;padding-bottom:10px;border-bottom:1px solid var(--line);}
31164 .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
31165 .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
31166 .ep-header:hover{background:var(--surface-2);}
31167 .method{display:inline-flex;align-items:center;justify-content:center;padding:3px 9px;border-radius:6px;font-size:11px;font-weight:800;letter-spacing:0.04em;flex:0 0 auto;text-transform:uppercase;}
31168 .method.get{background:#dcfce7;color:#166534;}
31169 .method.post{background:#dbeafe;color:#1e40af;}
31170 .method.delete{background:#fee2e2;color:#991b1b;}
31171 body.dark-theme .method.get{background:#14532d;color:#86efac;}
31172 body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
31173 body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
31174 .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
31175 .ep-path .param{color:var(--oxide-2);}
31176 body.dark-theme .ep-path .param{color:var(--oxide);}
31177 .auth-badge{display:inline-flex;align-items:center;gap:5px;padding:2px 9px;border-radius:999px;font-size:11px;font-weight:700;flex:0 0 auto;}
31178 .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
31179 .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
31180 .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
31181 body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
31182 body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
31183 body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
31184 .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
31185 .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
31186 .ep-card.open .chevron{transform:rotate(180deg);}
31187 .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
31188 .ep-card.open .ep-body{display:block;}
31189 .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
31190 .ep-desc-full code{background:rgba(0,0,0,0.06);border-radius:4px;padding:1px 5px;font-size:12px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;}
31191 .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
31192 body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
31193 .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
31194 table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
31195 table.params th{text-align:left;font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.06em;color:var(--muted-2);padding:5px 8px;border-bottom:1px solid var(--line);}
31196 table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
31197 table.params tr:last-child td{border-bottom:none;}
31198 .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
31199 .pt-type{color:var(--muted-2);font-size:12px;}
31200 .pt-req{display:inline-block;background:rgba(239,68,68,0.10);color:#b91c1c;border-radius:4px;padding:1px 6px;font-size:10px;font-weight:800;}
31201 .pt-opt{display:inline-block;background:rgba(0,0,0,0.06);color:var(--muted);border-radius:4px;padding:1px 6px;font-size:10px;font-weight:800;}
31202 body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
31203 body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
31204 details.schema{margin-bottom:14px;}
31205 details.schema summary{cursor:pointer;font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);padding:5px 0;user-select:none;}
31206 details.schema summary:hover{color:var(--text);}
31207 .schema-block{background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:12px 14px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:1.7;overflow-x:auto;white-space:pre;margin-top:6px;}
31208 .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
31209 .curl-wrap{position:relative;}
31210 .curl-block{background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:10px 80px 10px 14px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:1.6;overflow-x:auto;white-space:pre;margin:0;}
31211 .curl-copy-btn{position:absolute;right:8px;top:8px;padding:4px 10px;border-radius:6px;border:1px solid var(--line-strong);background:var(--surface);color:var(--muted);font-size:11px;font-weight:700;cursor:pointer;transition:background 0.15s,color 0.15s,border-color 0.15s;}
31212 .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
31213 .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
31214 .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
31215 .webhook-note a{color:var(--accent-2);text-decoration:none;}
31216 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31217 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
31218 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31219 .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;}
31220 @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));}}
31221 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
31222 .site-footer a{color:var(--muted);}
31223 </style>
31224</head>
31225<body>
31226 <div class="background-watermarks" aria-hidden="true">
31227 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31228 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31229 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31230 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31231 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31232 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31233 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31234 </div>
31235 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
31236 <div class="top-nav">
31237 <div class="top-nav-inner">
31238 <a class="brand" href="/">
31239 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
31240 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
31241 </a>
31242 <div class="nav-right">
31243 <a class="nav-pill" href="/">Home</a>
31244 <div class="nav-dropdown">
31245 <a href="/view-reports" class="nav-dropdown-btn">View Reports <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></a>
31246 <div class="nav-dropdown-menu">
31247 <a href="/trend-reports"><svg viewBox="0 0 24 24"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>Trend Reports</a>
31248 </div>
31249 </div>
31250 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
31251 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
31252 <div class="nav-dropdown">
31253 <a href="/git-browser" class="nav-dropdown-btn">Git Browser <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></a>
31254 <div class="nav-dropdown-menu">
31255 <a href="/integrations"><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>Integrations</a>
31256 </div>
31257 </div>
31258 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
31259 <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
31260 </button>
31261 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
31262 <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>
31263 <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>
31264 </button>
31265 </div>
31266 </div>
31267 </div>
31268
31269 <div class="page">
31270 <div class="page-header">
31271 <h1 class="page-title">REST API Reference</h1>
31272 <p class="page-subtitle">All endpoints exposed by this oxide-sloc server. Protected endpoints require authentication unless the server was started without an API key.</p>
31273 </div>
31274
31275 {% if has_api_key %}
31276 <div class="callout key-set">
31277 <svg class="callout-icon" viewBox="0 0 24 24" fill="none" stroke="#16a34a" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
31278 <div><strong>API key is configured.</strong> Protected endpoints require an <code>Authorization: Bearer <key></code> header, an <code>X-API-Key: <key></code> header, or an active session cookie from <code>POST /auth/login</code>.</div>
31279 </div>
31280 {% else %}
31281 <div class="callout no-key">
31282 <svg class="callout-icon" viewBox="0 0 24 24" fill="none" stroke="#d97706" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
31283 <div><strong>No API key set.</strong> All endpoints are publicly accessible on this server. Set <code>SLOC_API_KEY</code> or <code>SLOC_API_KEYS</code> to require authentication.</div>
31284 </div>
31285 {% endif %}
31286
31287 <div class="base-url-bar">
31288 <span class="base-url-label">Base URL</span>
31289 <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
31290 </div>
31291
31292 <!-- Health -->
31293 <div class="section">
31294 <h2 class="section-title">Health & Status</h2>
31295 <div class="ep-card">
31296 <div class="ep-header">
31297 <span class="method get">GET</span>
31298 <span class="ep-path">/healthz</span>
31299 <span class="auth-badge public">Public</span>
31300 <span class="ep-desc">Server liveness check</span>
31301 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31302 </div>
31303 <div class="ep-body">
31304 <p class="ep-desc-full">Returns the plain text string <code>ok</code> when the server is running. Suitable for load-balancer health probes and uptime monitors.</p>
31305 <p class="params-heading">Response</p>
31306 <div class="schema-block">200 OK
31307Content-Type: text/plain
31308
31309ok</div>
31310 <p class="curl-heading">Example</p>
31311 <div class="curl-wrap">
31312 <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
31313 <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
31314 </div>
31315 </div>
31316 </div>
31317 </div>
31318
31319 <!-- Badges -->
31320 <div class="section">
31321 <h2 class="section-title">Badges</h2>
31322 <div class="ep-card">
31323 <div class="ep-header">
31324 <span class="method get">GET</span>
31325 <span class="ep-path">/badge/<span class="param">{metric}</span></span>
31326 <span class="auth-badge public">Public</span>
31327 <span class="ep-desc">SVG badge for README / dashboard embedding</span>
31328 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31329 </div>
31330 <div class="ep-body">
31331 <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
31332 <p class="params-heading">Path Parameters</p>
31333 <table class="params">
31334 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31335 <tr><td class="pt-name">metric</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>One of: <code>code_lines</code>, <code>comment_lines</code>, <code>blank_lines</code>, <code>files_analyzed</code></td></tr>
31336 </table>
31337 <p class="curl-heading">Example</p>
31338 <div class="curl-wrap">
31339 <pre class="curl-block" data-curl-id="c-badge">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/badge/code_lines</pre>
31340 <button class="curl-copy-btn" data-target="c-badge">Copy</button>
31341 </div>
31342 </div>
31343 </div>
31344 </div>
31345
31346 <!-- Metrics -->
31347 <div class="section">
31348 <h2 class="section-title">Metrics</h2>
31349
31350 <div class="ep-card">
31351 <div class="ep-header">
31352 <span class="method get">GET</span>
31353 <span class="ep-path">/api/metrics/latest</span>
31354 <span class="auth-badge protected">Protected</span>
31355 <span class="ep-desc">Latest scan metrics (JSON)</span>
31356 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31357 </div>
31358 <div class="ep-body">
31359 <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
31360 <details class="schema"><summary>Response schema</summary>
31361<div class="schema-block">{
31362 "run_id": string, // UUID
31363 "timestamp": string, // ISO-8601 UTC
31364 "project": string, // scanned root path
31365 "summary": {
31366 "files_analyzed": number,
31367 "files_skipped": number,
31368 "code_lines": number,
31369 "comment_lines": number,
31370 "blank_lines": number,
31371 "total_physical_lines": number,
31372 "functions": number,
31373 "classes": number,
31374 "variables": number,
31375 "imports": number
31376 },
31377 "languages": [
31378 { "name": string, "files": number, "code_lines": number,
31379 "comment_lines": number, "blank_lines": number,
31380 "functions": number, "classes": number,
31381 "variables": number, "imports": number }
31382 ]
31383}</div></details>
31384 <p class="curl-heading">Example</p>
31385 <div class="curl-wrap">
31386 <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31387 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
31388 <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
31389 </div>
31390 </div>
31391 </div>
31392
31393 <div class="ep-card">
31394 <div class="ep-header">
31395 <span class="method get">GET</span>
31396 <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
31397 <span class="auth-badge protected">Protected</span>
31398 <span class="ep-desc">Metrics for a specific run</span>
31399 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31400 </div>
31401 <div class="ep-body">
31402 <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
31403 <p class="params-heading">Path Parameters</p>
31404 <table class="params">
31405 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31406 <tr><td class="pt-name">run_id</td><td class="pt-type">string (UUID)</td><td><span class="pt-req">required</span></td><td>Run UUID from <code>/api/metrics/history</code></td></tr>
31407 </table>
31408 <p class="curl-heading">Example</p>
31409 <div class="curl-wrap">
31410 <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31411 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/<run_id></pre>
31412 <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
31413 </div>
31414 </div>
31415 </div>
31416
31417 <div class="ep-card">
31418 <div class="ep-header">
31419 <span class="method get">GET</span>
31420 <span class="ep-path">/api/metrics/history</span>
31421 <span class="auth-badge protected">Protected</span>
31422 <span class="ep-desc">Paginated scan history</span>
31423 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31424 </div>
31425 <div class="ep-body">
31426 <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
31427 <p class="params-heading">Query Parameters</p>
31428 <table class="params">
31429 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31430 <tr><td class="pt-name">root</td><td class="pt-type">string</td><td><span class="pt-opt">optional</span></td><td>Filter by scanned root path</td></tr>
31431 <tr><td class="pt-name">limit</td><td class="pt-type">number</td><td><span class="pt-opt">optional</span></td><td>Max entries to return (default: 50)</td></tr>
31432 </table>
31433 <details class="schema"><summary>Response schema</summary>
31434<div class="schema-block">[{
31435 "run_id": string,
31436 "timestamp": string, // ISO-8601 UTC
31437 "commit": string | null,
31438 "branch": string | null,
31439 "tags": string[],
31440 "code_lines": number,
31441 "comment_lines": number,
31442 "blank_lines": number,
31443 "physical_lines": number,
31444 "files_analyzed": number,
31445 "project_label": string,
31446 "html_url": string | null
31447}]</div></details>
31448 <p class="curl-heading">Example</p>
31449 <div class="curl-wrap">
31450 <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31451 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
31452 <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
31453 </div>
31454 </div>
31455 </div>
31456
31457 <div class="ep-card">
31458 <div class="ep-header">
31459 <span class="method get">GET</span>
31460 <span class="ep-path">/api/project-history</span>
31461 <span class="auth-badge protected">Protected</span>
31462 <span class="ep-desc">Project-level scan summary</span>
31463 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31464 </div>
31465 <div class="ep-body">
31466 <p class="ep-desc-full">Returns a high-level project summary: total scans, last scan ID and timestamp, last code-line count, and most recent git metadata.</p>
31467 <p class="params-heading">Query Parameters</p>
31468 <table class="params">
31469 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31470 <tr><td class="pt-name">path</td><td class="pt-type">string</td><td><span class="pt-opt">optional</span></td><td>Filter by root path</td></tr>
31471 </table>
31472 <details class="schema"><summary>Response schema</summary>
31473<div class="schema-block">{
31474 "scan_count": number,
31475 "last_scan_id": string | null,
31476 "last_scan_timestamp": string | null, // ISO-8601
31477 "last_scan_code_lines": number | null,
31478 "last_git_branch": string | null,
31479 "last_git_commit": string | null
31480}</div></details>
31481 <p class="curl-heading">Example</p>
31482 <div class="curl-wrap">
31483 <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31484 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
31485 <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
31486 </div>
31487 </div>
31488 </div>
31489
31490 <div class="ep-card">
31491 <div class="ep-header">
31492 <span class="method get">GET</span>
31493 <span class="ep-path">/api/metrics/submodules</span>
31494 <span class="auth-badge protected">Protected</span>
31495 <span class="ep-desc">List known git submodules across scans</span>
31496 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31497 </div>
31498 <div class="ep-body">
31499 <p class="ep-desc-full">Returns the distinct set of git submodules that have appeared in any stored scan, optionally filtered by project root path.</p>
31500 <p class="params-heading">Query Parameters</p>
31501 <table class="params">
31502 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31503 <tr><td class="pt-name">root</td><td class="pt-type">string</td><td><span class="pt-opt">optional</span></td><td>Filter to scans whose input root matches this path</td></tr>
31504 </table>
31505 <details class="schema"><summary>Response schema</summary>
31506<div class="schema-block">[{
31507 "name": string, // submodule name
31508 "relative_path": string // path relative to the project root
31509}]</div></details>
31510 <p class="curl-heading">Example</p>
31511 <div class="curl-wrap">
31512 <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31513 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
31514 <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
31515 </div>
31516 </div>
31517 </div>
31518 </div>
31519
31520 <!-- Async Run Status -->
31521 <div class="section">
31522 <h2 class="section-title">Async Run Status</h2>
31523
31524 <div class="ep-card">
31525 <div class="ep-header">
31526 <span class="method get">GET</span>
31527 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
31528 <span class="auth-badge protected">Protected</span>
31529 <span class="ep-desc">Poll scan completion</span>
31530 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31531 </div>
31532 <div class="ep-body">
31533 <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
31534 <details class="schema"><summary>Response schema</summary>
31535<div class="schema-block">// Running
31536{ "state": "running", "elapsed_secs": number }
31537
31538// Complete
31539{ "state": "complete", "run_id": string }
31540
31541// Failed
31542{ "state": "failed", "message": string }</div></details>
31543 <p class="curl-heading">Example</p>
31544 <div class="curl-wrap">
31545 <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31546 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/status</pre>
31547 <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
31548 </div>
31549 </div>
31550 </div>
31551
31552 <div class="ep-card">
31553 <div class="ep-header">
31554 <span class="method get">GET</span>
31555 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
31556 <span class="auth-badge protected">Protected</span>
31557 <span class="ep-desc">Poll PDF generation readiness</span>
31558 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31559 </div>
31560 <div class="ep-body">
31561 <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
31562 <details class="schema"><summary>Response schema</summary>
31563<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
31564 <p class="curl-heading">Example</p>
31565 <div class="curl-wrap">
31566 <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31567 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/pdf-status</pre>
31568 <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
31569 </div>
31570 </div>
31571 </div>
31572
31573 <div class="ep-card">
31574 <div class="ep-header">
31575 <span class="method post">POST</span>
31576 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
31577 <span class="auth-badge protected">Protected</span>
31578 <span class="ep-desc">Cancel a running scan</span>
31579 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31580 </div>
31581 <div class="ep-body">
31582 <p class="ep-desc-full">Signals a running async scan to stop. Returns <code>200 OK</code> if cancellation was accepted or the scan was already cancelled. Returns <code>404</code> if the run ID is unknown or the scan has already completed.</p>
31583 <p class="curl-heading">Example</p>
31584 <div class="curl-wrap">
31585 <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
31586 -H "Authorization: Bearer $SLOC_API_KEY" \
31587 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/cancel</pre>
31588 <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
31589 </div>
31590 </div>
31591 </div>
31592 </div>
31593
31594 <!-- Run Management -->
31595 <div class="section">
31596 <h2 class="section-title">Run Management</h2>
31597
31598 <div class="ep-card">
31599 <div class="ep-header">
31600 <span class="method get">GET</span>
31601 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/bundle</span>
31602 <span class="auth-badge protected">Protected</span>
31603 <span class="ep-desc">Download all artifacts for a run as a ZIP archive</span>
31604 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31605 </div>
31606 <div class="ep-body">
31607 <p class="ep-desc-full">Returns a <code>.zip</code> archive containing every artifact stored for the run: HTML report, PDF, JSON result, CSV, Excel workbook, and scan config TOML. Useful for offline archiving or migration.</p>
31608 <p class="params-heading">Path Parameters</p>
31609 <table class="params">
31610 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31611 <tr><td class="pt-name">run_id</td><td class="pt-type">string (UUID)</td><td><span class="pt-req">required</span></td><td>Run UUID from <code>/api/metrics/history</code></td></tr>
31612 </table>
31613 <details class="schema"><summary>Response</summary>
31614<div class="schema-block">200 OK — Content-Type: application/zip
31615Content-Disposition: attachment; filename="sloc-run-<run_id>.zip"
31616
31617404 Not Found — { "error": string } (run not found or no artifacts)</div></details>
31618 <p class="curl-heading">Example</p>
31619 <div class="curl-wrap">
31620 <pre class="curl-block" data-curl-id="c-run-bundle">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31621 -o run.zip \
31622 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/bundle</pre>
31623 <button class="curl-copy-btn" data-target="c-run-bundle">Copy</button>
31624 </div>
31625 </div>
31626 </div>
31627
31628 <div class="ep-card">
31629 <div class="ep-header">
31630 <span class="method delete">DELETE</span>
31631 <span class="ep-path">/api/runs/<span class="param">{run_id}</span></span>
31632 <span class="auth-badge protected">Protected</span>
31633 <span class="ep-desc">Permanently delete a run and all its artifacts</span>
31634 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31635 </div>
31636 <div class="ep-body">
31637 <p class="ep-desc-full">Removes all on-disk artifacts for the run (HTML, PDF, JSON, CSV, Excel, scan config), purges the entry from the in-memory cache, and removes it from the persisted scan registry. <strong>This action is irreversible.</strong></p>
31638 <p class="params-heading">Path Parameters</p>
31639 <table class="params">
31640 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31641 <tr><td class="pt-name">run_id</td><td class="pt-type">string (UUID)</td><td><span class="pt-req">required</span></td><td>Run UUID to delete</td></tr>
31642 </table>
31643 <details class="schema"><summary>Response</summary>
31644<div class="schema-block">204 No Content — run successfully deleted
31645
31646500 Internal Server Error — { "error": string } (filesystem deletion failed)</div></details>
31647 <p class="curl-heading">Example</p>
31648 <div class="curl-wrap">
31649 <pre class="curl-block" data-curl-id="c-run-delete">curl -X DELETE \
31650 -H "Authorization: Bearer $SLOC_API_KEY" \
31651 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id></pre>
31652 <button class="curl-copy-btn" data-target="c-run-delete">Copy</button>
31653 </div>
31654 </div>
31655 </div>
31656
31657 <div class="ep-card">
31658 <div class="ep-header">
31659 <span class="method post">POST</span>
31660 <span class="ep-path">/api/runs/cleanup</span>
31661 <span class="auth-badge protected">Protected</span>
31662 <span class="ep-desc">Bulk delete runs older than N days</span>
31663 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31664 </div>
31665 <div class="ep-body">
31666 <p class="ep-desc-full">One-shot age-based cleanup. Deletes all on-disk artifacts and registry entries for runs whose timestamp is older than <code>older_than_days</code> days. For automated recurring cleanup, use the Retention Policy endpoints instead.</p>
31667 <p class="params-heading">Request Body (application/json)</p>
31668 <table class="params">
31669 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31670 <tr><td class="pt-name">older_than_days</td><td class="pt-type">integer</td><td><span class="pt-opt">optional</span></td><td>Delete runs older than this many days. Default: <code>30</code>. Minimum: <code>1</code>.</td></tr>
31671 </table>
31672 <details class="schema"><summary>Response schema</summary>
31673<div class="schema-block">{ "deleted": number } // count of runs removed</div></details>
31674 <p class="curl-heading">Example — delete runs older than 60 days</p>
31675 <div class="curl-wrap">
31676 <pre class="curl-block" data-curl-id="c-runs-cleanup">curl -X POST \
31677 -H "Authorization: Bearer $SLOC_API_KEY" \
31678 -H "Content-Type: application/json" \
31679 -d '{"older_than_days":60}' \
31680 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/cleanup</pre>
31681 <button class="curl-copy-btn" data-target="c-runs-cleanup">Copy</button>
31682 </div>
31683 </div>
31684 </div>
31685 </div>
31686
31687 <!-- Retention Policy -->
31688 <div class="section">
31689 <h2 class="section-title">Retention Policy</h2>
31690
31691 <div class="ep-card">
31692 <div class="ep-header">
31693 <span class="method get">GET</span>
31694 <span class="ep-path">/api/cleanup-policy</span>
31695 <span class="auth-badge protected">Protected</span>
31696 <span class="ep-desc">Get the current retention policy and last-run metadata</span>
31697 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31698 </div>
31699 <div class="ep-body">
31700 <p class="ep-desc-full">Returns the configured auto-cleanup policy (if any) together with the timestamp and count from the last background cleanup pass. Useful for monitoring whether the policy is running as expected.</p>
31701 <details class="schema"><summary>Response schema</summary>
31702<div class="schema-block">{
31703 "policy": {
31704 "enabled": boolean,
31705 "max_age_days": number | null, // delete runs older than N days
31706 "max_run_count": number | null, // keep only the N most recent runs
31707 "interval_hours": number // hours between background passes
31708 } | null,
31709 "last_run_at": string | null, // ISO-8601 UTC timestamp
31710 "last_run_deleted": number | null // runs deleted in last pass
31711}</div></details>
31712 <p class="curl-heading">Example</p>
31713 <div class="curl-wrap">
31714 <pre class="curl-block" data-curl-id="c-policy-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31715 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
31716 <button class="curl-copy-btn" data-target="c-policy-get">Copy</button>
31717 </div>
31718 </div>
31719 </div>
31720
31721 <div class="ep-card">
31722 <div class="ep-header">
31723 <span class="method post">POST</span>
31724 <span class="ep-path">/api/cleanup-policy</span>
31725 <span class="auth-badge protected">Protected</span>
31726 <span class="ep-desc">Save or update the retention policy</span>
31727 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31728 </div>
31729 <div class="ep-body">
31730 <p class="ep-desc-full">Persists a new retention policy to <code>cleanup_policy.json</code>. If <code>enabled</code> is <code>true</code>, the existing background task is stopped and a new one is started at the given interval. Both rules apply when set — a run is deleted if it exceeds the age limit <em>or</em> falls outside the count limit.</p>
31731 <p class="params-heading">Request Body (application/json)</p>
31732 <table class="params">
31733 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31734 <tr><td class="pt-name">enabled</td><td class="pt-type">boolean</td><td><span class="pt-req">required</span></td><td>Whether to activate the background cleanup task</td></tr>
31735 <tr><td class="pt-name">max_age_days</td><td class="pt-type">integer | null</td><td><span class="pt-opt">optional</span></td><td>Delete runs older than N days. Omit or <code>null</code> to disable age-based cleanup.</td></tr>
31736 <tr><td class="pt-name">max_run_count</td><td class="pt-type">integer | null</td><td><span class="pt-opt">optional</span></td><td>Keep only the N most recent runs. Omit or <code>null</code> to disable count-based cleanup.</td></tr>
31737 <tr><td class="pt-name">interval_hours</td><td class="pt-type">integer</td><td><span class="pt-req">required</span></td><td>Hours between background cleanup passes. Minimum: <code>1</code>.</td></tr>
31738 </table>
31739 <details class="schema"><summary>Response</summary>
31740<div class="schema-block">204 No Content — policy saved and task (re)started
31741
31742500 Internal Server Error — { "error": string }</div></details>
31743 <p class="curl-heading">Example — keep 30 days, max 100 runs, check daily</p>
31744 <div class="curl-wrap">
31745 <pre class="curl-block" data-curl-id="c-policy-post">curl -X POST \
31746 -H "Authorization: Bearer $SLOC_API_KEY" \
31747 -H "Content-Type: application/json" \
31748 -d '{"enabled":true,"max_age_days":30,"max_run_count":100,"interval_hours":24}' \
31749 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
31750 <button class="curl-copy-btn" data-target="c-policy-post">Copy</button>
31751 </div>
31752 </div>
31753 </div>
31754
31755 <div class="ep-card">
31756 <div class="ep-header">
31757 <span class="method post">POST</span>
31758 <span class="ep-path">/api/cleanup-policy/run-now</span>
31759 <span class="auth-badge protected">Protected</span>
31760 <span class="ep-desc">Trigger an immediate cleanup pass</span>
31761 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31762 </div>
31763 <div class="ep-body">
31764 <p class="ep-desc-full">Executes the configured retention policy immediately, outside of the normal background schedule. Returns the number of runs deleted. The policy must already be saved (via <code>POST /api/cleanup-policy</code>) before calling this endpoint, but does not need to be enabled.</p>
31765 <details class="schema"><summary>Response schema</summary>
31766<div class="schema-block">{ "deleted": number } // count of runs removed in this pass</div></details>
31767 <p class="curl-heading">Example</p>
31768 <div class="curl-wrap">
31769 <pre class="curl-block" data-curl-id="c-policy-run-now">curl -X POST \
31770 -H "Authorization: Bearer $SLOC_API_KEY" \
31771 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy/run-now</pre>
31772 <button class="curl-copy-btn" data-target="c-policy-run-now">Copy</button>
31773 </div>
31774 </div>
31775 </div>
31776
31777 <div class="ep-card">
31778 <div class="ep-header">
31779 <span class="method delete">DELETE</span>
31780 <span class="ep-path">/api/cleanup-policy</span>
31781 <span class="auth-badge protected">Protected</span>
31782 <span class="ep-desc">Remove the retention policy and stop the background task</span>
31783 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31784 </div>
31785 <div class="ep-body">
31786 <p class="ep-desc-full">Clears the saved retention policy and stops the background cleanup task if it is running. Does not delete any existing scan runs.</p>
31787 <details class="schema"><summary>Response</summary>
31788<div class="schema-block">204 No Content — policy removed and task stopped</div></details>
31789 <p class="curl-heading">Example</p>
31790 <div class="curl-wrap">
31791 <pre class="curl-block" data-curl-id="c-policy-delete">curl -X DELETE \
31792 -H "Authorization: Bearer $SLOC_API_KEY" \
31793 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
31794 <button class="curl-copy-btn" data-target="c-policy-delete">Copy</button>
31795 </div>
31796 </div>
31797 </div>
31798 </div>
31799
31800 <!-- Scan Profiles -->
31801 <div class="section">
31802 <h2 class="section-title">Scan Profiles</h2>
31803
31804 <div class="ep-card">
31805 <div class="ep-header">
31806 <span class="method get">GET</span>
31807 <span class="ep-path">/api/scan-profiles</span>
31808 <span class="auth-badge protected">Protected</span>
31809 <span class="ep-desc">List saved scan profiles</span>
31810 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31811 </div>
31812 <div class="ep-body">
31813 <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
31814 <details class="schema"><summary>Response schema</summary>
31815<div class="schema-block">{
31816 "profiles": [{
31817 "id": string, // UUID
31818 "name": string,
31819 "created_at": string, // ISO-8601
31820 "params": object
31821 }]
31822}</div></details>
31823 <p class="curl-heading">Example</p>
31824 <div class="curl-wrap">
31825 <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31826 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
31827 <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
31828 </div>
31829 </div>
31830 </div>
31831
31832 <div class="ep-card">
31833 <div class="ep-header">
31834 <span class="method post">POST</span>
31835 <span class="ep-path">/api/scan-profiles</span>
31836 <span class="auth-badge protected">Protected</span>
31837 <span class="ep-desc">Save a scan profile</span>
31838 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31839 </div>
31840 <div class="ep-body">
31841 <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
31842 <p class="params-heading">Request Body (application/json)</p>
31843 <table class="params">
31844 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31845 <tr><td class="pt-name">name</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Human-readable profile name</td></tr>
31846 <tr><td class="pt-name">params</td><td class="pt-type">object</td><td><span class="pt-req">required</span></td><td>Arbitrary scan parameter object</td></tr>
31847 </table>
31848 <details class="schema"><summary>Response schema</summary>
31849<div class="schema-block">{ "ok": true }</div></details>
31850 <p class="curl-heading">Example</p>
31851 <div class="curl-wrap">
31852 <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
31853 -H "Authorization: Bearer $SLOC_API_KEY" \
31854 -H "Content-Type: application/json" \
31855 -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
31856 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
31857 <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
31858 </div>
31859 </div>
31860 </div>
31861
31862 <div class="ep-card">
31863 <div class="ep-header">
31864 <span class="method delete">DELETE</span>
31865 <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
31866 <span class="auth-badge protected">Protected</span>
31867 <span class="ep-desc">Delete a scan profile</span>
31868 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31869 </div>
31870 <div class="ep-body">
31871 <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
31872 <p class="params-heading">Path Parameters</p>
31873 <table class="params">
31874 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31875 <tr><td class="pt-name">id</td><td class="pt-type">string (UUID)</td><td><span class="pt-req">required</span></td><td>Profile UUID from <code>GET /api/scan-profiles</code></td></tr>
31876 </table>
31877 <details class="schema"><summary>Response schema</summary>
31878<div class="schema-block">{ "ok": true }</div></details>
31879 <p class="curl-heading">Example</p>
31880 <div class="curl-wrap">
31881 <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
31882 -H "Authorization: Bearer $SLOC_API_KEY" \
31883 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/<id></pre>
31884 <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
31885 </div>
31886 </div>
31887 </div>
31888 </div>
31889
31890 <!-- Scheduled Scans -->
31891 <div class="section">
31892 <h2 class="section-title">Scheduled Scans</h2>
31893
31894 <div class="ep-card">
31895 <div class="ep-header">
31896 <span class="method get">GET</span>
31897 <span class="ep-path">/api/schedules</span>
31898 <span class="auth-badge protected">Protected</span>
31899 <span class="ep-desc">List configured schedules</span>
31900 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31901 </div>
31902 <div class="ep-body">
31903 <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
31904 <p class="curl-heading">Example</p>
31905 <div class="curl-wrap">
31906 <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31907 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
31908 <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
31909 </div>
31910 </div>
31911 </div>
31912
31913 <div class="ep-card">
31914 <div class="ep-header">
31915 <span class="method post">POST</span>
31916 <span class="ep-path">/api/schedules</span>
31917 <span class="auth-badge protected">Protected</span>
31918 <span class="ep-desc">Create a schedule</span>
31919 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31920 </div>
31921 <div class="ep-body">
31922 <p class="ep-desc-full">Creates a new scheduled scan. Use the <a href="/integrations">Integrations UI</a> to configure the full field set interactively.</p>
31923 <p class="curl-heading">Example</p>
31924 <div class="curl-wrap">
31925 <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
31926 -H "Authorization: Bearer $SLOC_API_KEY" \
31927 -H "Content-Type: application/json" \
31928 -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
31929 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
31930 <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
31931 </div>
31932 </div>
31933 </div>
31934
31935 <div class="ep-card">
31936 <div class="ep-header">
31937 <span class="method delete">DELETE</span>
31938 <span class="ep-path">/api/schedules</span>
31939 <span class="auth-badge protected">Protected</span>
31940 <span class="ep-desc">Delete a schedule</span>
31941 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31942 </div>
31943 <div class="ep-body">
31944 <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
31945 <p class="curl-heading">Example</p>
31946 <div class="curl-wrap">
31947 <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
31948 -H "Authorization: Bearer $SLOC_API_KEY" \
31949 -H "Content-Type: application/json" \
31950 -d '{"id":"<schedule_id>"}' \
31951 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
31952 <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
31953 </div>
31954 </div>
31955 </div>
31956 </div>
31957
31958 <!-- Git Browser -->
31959 <div class="section">
31960 <h2 class="section-title">Git Browser</h2>
31961
31962 <div class="ep-card">
31963 <div class="ep-header">
31964 <span class="method get">GET</span>
31965 <span class="ep-path">/api/git/refs</span>
31966 <span class="auth-badge protected">Protected</span>
31967 <span class="ep-desc">List git refs for a repository</span>
31968 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31969 </div>
31970 <div class="ep-body">
31971 <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
31972 <p class="params-heading">Query Parameters</p>
31973 <table class="params">
31974 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31975 <tr><td class="pt-name">path</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Absolute path to a local git repository</td></tr>
31976 </table>
31977 <p class="curl-heading">Example</p>
31978 <div class="curl-wrap">
31979 <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31980 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?path=/path/to/repo"</pre>
31981 <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
31982 </div>
31983 </div>
31984 </div>
31985
31986 <div class="ep-card">
31987 <div class="ep-header">
31988 <span class="method get">GET</span>
31989 <span class="ep-path">/api/git/scan-ref</span>
31990 <span class="auth-badge protected">Protected</span>
31991 <span class="ep-desc">SLOC-scan a specific git ref</span>
31992 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31993 </div>
31994 <div class="ep-body">
31995 <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
31996 <p class="params-heading">Query Parameters</p>
31997 <table class="params">
31998 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31999 <tr><td class="pt-name">path</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Absolute path to a local git repository</td></tr>
32000 <tr><td class="pt-name">ref</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Branch name, tag, or commit SHA</td></tr>
32001 </table>
32002 <p class="curl-heading">Example</p>
32003 <div class="curl-wrap">
32004 <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32005 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?path=/path/to/repo&ref=main"</pre>
32006 <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
32007 </div>
32008 </div>
32009 </div>
32010
32011 <div class="ep-card">
32012 <div class="ep-header">
32013 <span class="method get">GET</span>
32014 <span class="ep-path">/api/git/compare-refs</span>
32015 <span class="auth-badge protected">Protected</span>
32016 <span class="ep-desc">Compare SLOC across two git refs</span>
32017 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32018 </div>
32019 <div class="ep-body">
32020 <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
32021 <p class="params-heading">Query Parameters</p>
32022 <table class="params">
32023 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32024 <tr><td class="pt-name">path</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Absolute path to a local git repository</td></tr>
32025 <tr><td class="pt-name">base</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Base ref (branch, tag, or SHA)</td></tr>
32026 <tr><td class="pt-name">head</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Head ref to compare against the base</td></tr>
32027 </table>
32028 <p class="curl-heading">Example</p>
32029 <div class="curl-wrap">
32030 <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32031 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/compare-refs?path=/path/to/repo&base=v1.0&head=main"</pre>
32032 <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
32033 </div>
32034 </div>
32035 </div>
32036 </div>
32037
32038 <!-- Webhooks -->
32039 <div class="section">
32040 <h2 class="section-title">Webhooks</h2>
32041 <p class="webhook-note">Webhook receivers are public endpoints authenticated by per-schedule HMAC secrets, not by the server API key. Configure secrets in <a href="/integrations">Integrations</a>.</p>
32042
32043 <div class="ep-card">
32044 <div class="ep-header">
32045 <span class="method post">POST</span>
32046 <span class="ep-path">/webhooks/github</span>
32047 <span class="auth-badge hmac">HMAC</span>
32048 <span class="ep-desc">GitHub push event receiver</span>
32049 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32050 </div>
32051 <div class="ep-body">
32052 <p class="ep-desc-full">Receives GitHub <code>push</code> events and triggers an SLOC scan. Authenticated via <code>X-Hub-Signature-256</code> HMAC-SHA256.</p>
32053 <p class="params-heading">Required Headers</p>
32054 <table class="params">
32055 <tr><th>Header</th><th>Value</th></tr>
32056 <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
32057 <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
32058 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
32059 </table>
32060 </div>
32061 </div>
32062
32063 <div class="ep-card">
32064 <div class="ep-header">
32065 <span class="method post">POST</span>
32066 <span class="ep-path">/webhooks/gitlab</span>
32067 <span class="auth-badge hmac">HMAC</span>
32068 <span class="ep-desc">GitLab push event receiver</span>
32069 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32070 </div>
32071 <div class="ep-body">
32072 <p class="ep-desc-full">Receives GitLab <code>Push Hook</code> events. Authenticated via <code>X-Gitlab-Token</code> matching the per-schedule secret.</p>
32073 <p class="params-heading">Required Headers</p>
32074 <table class="params">
32075 <tr><th>Header</th><th>Value</th></tr>
32076 <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
32077 <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
32078 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
32079 </table>
32080 </div>
32081 </div>
32082
32083 <div class="ep-card">
32084 <div class="ep-header">
32085 <span class="method post">POST</span>
32086 <span class="ep-path">/webhooks/bitbucket</span>
32087 <span class="auth-badge hmac">HMAC</span>
32088 <span class="ep-desc">Bitbucket push event receiver</span>
32089 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32090 </div>
32091 <div class="ep-body">
32092 <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
32093 <p class="params-heading">Required Headers</p>
32094 <table class="params">
32095 <tr><th>Header</th><th>Value</th></tr>
32096 <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
32097 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
32098 </table>
32099 </div>
32100 </div>
32101 </div>
32102
32103 <!-- Config -->
32104 <div class="section">
32105 <h2 class="section-title">Config Import / Export</h2>
32106
32107 <div class="ep-card">
32108 <div class="ep-header">
32109 <span class="method get">GET</span>
32110 <span class="ep-path">/export-config</span>
32111 <span class="auth-badge protected">Protected</span>
32112 <span class="ep-desc">Export server configuration as JSON</span>
32113 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32114 </div>
32115 <div class="ep-body">
32116 <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
32117 <p class="curl-heading">Example</p>
32118 <div class="curl-wrap">
32119 <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32120 -o config.json \
32121 <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
32122 <button class="curl-copy-btn" data-target="c-export">Copy</button>
32123 </div>
32124 </div>
32125 </div>
32126
32127 <div class="ep-card">
32128 <div class="ep-header">
32129 <span class="method post">POST</span>
32130 <span class="ep-path">/import-config</span>
32131 <span class="auth-badge protected">Protected</span>
32132 <span class="ep-desc">Import server configuration</span>
32133 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32134 </div>
32135 <div class="ep-body">
32136 <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
32137 <p class="curl-heading">Example</p>
32138 <div class="curl-wrap">
32139 <pre class="curl-block" data-curl-id="c-import">curl -X POST \
32140 -H "Authorization: Bearer $SLOC_API_KEY" \
32141 -H "Content-Type: application/json" \
32142 -d @config.json \
32143 <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
32144 <button class="curl-copy-btn" data-target="c-import">Copy</button>
32145 </div>
32146 </div>
32147 </div>
32148 </div>
32149
32150 <!-- CI Ingest -->
32151 <div class="section">
32152 <h2 class="section-title">CI Ingest</h2>
32153
32154 <div class="ep-card">
32155 <div class="ep-header">
32156 <span class="method post">POST</span>
32157 <span class="ep-path">/api/ingest</span>
32158 <span class="auth-badge protected">Protected</span>
32159 <span class="ep-desc">Push a pre-computed scan result from CI</span>
32160 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32161 </div>
32162 <div class="ep-body">
32163 <p class="ep-desc-full">Accepts a pre-computed <code>AnalysisRun</code> JSON (produced by <code>oxide-sloc analyze --json-out result.json</code>) and stores it as if a server-side scan had been run. Use <code>oxide-sloc send result.json --webhook-url <server>/api/ingest</code> for the canonical CLI workflow.</p>
32164 <p class="params-heading">Query Parameters</p>
32165 <table class="params">
32166 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32167 <tr><td class="pt-name">label</td><td class="pt-type">string</td><td><span class="pt-opt">optional</span></td><td>Display name shown in View Reports (defaults to the scanned root path)</td></tr>
32168 </table>
32169 <p class="params-heading">Request Body (application/json)</p>
32170 <p style="margin:0 0 8px;font-size:13px;color:var(--muted);">Full <code>AnalysisRun</code> JSON as produced by the CLI <code>--json-out</code> flag.</p>
32171 <details class="schema"><summary>Response schema</summary>
32172<div class="schema-block">// 201 Created
32173{
32174 "run_id": string, // UUID of the ingested run
32175 "view_url": string // relative URL to the report page
32176}</div></details>
32177 <p class="curl-heading">Example</p>
32178 <div class="curl-wrap">
32179 <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
32180 -H "Authorization: Bearer $SLOC_API_KEY" \
32181 -H "Content-Type: application/json" \
32182 -d @result.json \
32183 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
32184 <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
32185 </div>
32186 </div>
32187 </div>
32188 </div>
32189
32190 <!-- Artifact Download -->
32191 <div class="section">
32192 <h2 class="section-title">Artifact Download</h2>
32193
32194 <div class="ep-card">
32195 <div class="ep-header">
32196 <span class="method get">GET</span>
32197 <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
32198 <span class="auth-badge protected">Protected</span>
32199 <span class="ep-desc">Download or view a scan artifact</span>
32200 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32201 </div>
32202 <div class="ep-body">
32203 <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
32204 <p class="params-heading">Path Parameters</p>
32205 <table class="params">
32206 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32207 <tr><td class="pt-name">artifact</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>One of: <code>html</code> (rendered report), <code>pdf</code> (PDF export), <code>json</code> (raw AnalysisRun), <code>scan-config</code> (TOML config used)</td></tr>
32208 <tr><td class="pt-name">run_id</td><td class="pt-type">string (UUID)</td><td><span class="pt-req">required</span></td><td>Run UUID from <code>/api/metrics/history</code></td></tr>
32209 </table>
32210 <p class="params-heading">Query Parameters</p>
32211 <table class="params">
32212 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32213 <tr><td class="pt-name">download</td><td class="pt-type">string</td><td><span class="pt-opt">optional</span></td><td>Pass <code>1</code> to force a <code>Content-Disposition: attachment</code> download header</td></tr>
32214 </table>
32215 <p class="curl-heading">Example — download JSON result</p>
32216 <div class="curl-wrap">
32217 <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32218 -o result.json \
32219 "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/<run_id>?download=1"</pre>
32220 <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
32221 </div>
32222 </div>
32223 </div>
32224 </div>
32225
32226 <!-- Embed Widget -->
32227 <div class="section">
32228 <h2 class="section-title">Embed Widget</h2>
32229
32230 <div class="ep-card">
32231 <div class="ep-header">
32232 <span class="method get">GET</span>
32233 <span class="ep-path">/embed/summary</span>
32234 <span class="auth-badge protected">Protected</span>
32235 <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
32236 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32237 </div>
32238 <div class="ep-body">
32239 <p class="ep-desc-full">Returns a self-contained HTML snippet suitable for embedding in an <code><iframe></code>. Shows key metrics (code lines, file count, language breakdown) for the specified or most recent run.</p>
32240 <p class="params-heading">Query Parameters</p>
32241 <table class="params">
32242 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32243 <tr><td class="pt-name">run_id</td><td class="pt-type">string (UUID)</td><td><span class="pt-opt">optional</span></td><td>Run to display; defaults to the most recent scan</td></tr>
32244 <tr><td class="pt-name">theme</td><td class="pt-type">string</td><td><span class="pt-opt">optional</span></td><td>Pass <code>dark</code> for a dark-themed widget</td></tr>
32245 </table>
32246 <p class="curl-heading">Example</p>
32247 <div class="curl-wrap">
32248 <pre class="curl-block" data-curl-id="c-embed"><iframe src="<span class="base-url-slot">http://127.0.0.1:4317</span>/embed/summary?theme=dark"
32249 width="460" height="260" style="border:none"></iframe></pre>
32250 <button class="curl-copy-btn" data-target="c-embed">Copy</button>
32251 </div>
32252 </div>
32253 </div>
32254 </div>
32255
32256 <!-- Confluence Integration -->
32257 <div class="section">
32258 <h2 class="section-title">Confluence Integration</h2>
32259
32260 <div class="ep-card">
32261 <div class="ep-header">
32262 <span class="method get">GET</span>
32263 <span class="ep-path">/api/confluence/config</span>
32264 <span class="auth-badge protected">Protected</span>
32265 <span class="ep-desc">Get current Confluence configuration</span>
32266 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32267 </div>
32268 <div class="ep-body">
32269 <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
32270 <details class="schema"><summary>Response schema</summary>
32271<div class="schema-block">{
32272 "configured": boolean,
32273 "tier": "cloud" | "server",
32274 "base_url": string,
32275 "username": string,
32276 "api_token_set": boolean,
32277 "space_key": string,
32278 "parent_page_id": string | null,
32279 "schedule_auto_post": { "<schedule_id>": boolean }
32280}</div></details>
32281 <p class="curl-heading">Example</p>
32282 <div class="curl-wrap">
32283 <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32284 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
32285 <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
32286 </div>
32287 </div>
32288 </div>
32289
32290 <div class="ep-card">
32291 <div class="ep-header">
32292 <span class="method post">POST</span>
32293 <span class="ep-path">/api/confluence/config</span>
32294 <span class="auth-badge protected">Protected</span>
32295 <span class="ep-desc">Save Confluence configuration</span>
32296 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32297 </div>
32298 <div class="ep-body">
32299 <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
32300 <p class="params-heading">Request Body (application/json)</p>
32301 <table class="params">
32302 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32303 <tr><td class="pt-name">tier</td><td class="pt-type">string</td><td><span class="pt-opt">optional</span></td><td><code>cloud</code> (default) or <code>server</code></td></tr>
32304 <tr><td class="pt-name">base_url</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Confluence base URL (e.g. <code>https://myorg.atlassian.net</code>)</td></tr>
32305 <tr><td class="pt-name">username</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Atlassian account email / server username</td></tr>
32306 <tr><td class="pt-name">credential</td><td class="pt-type">string</td><td><span class="pt-opt">optional</span></td><td>API token or password; blank to keep existing</td></tr>
32307 <tr><td class="pt-name">space_key</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Confluence space key (e.g. <code>ENG</code>)</td></tr>
32308 <tr><td class="pt-name">parent_page_id</td><td class="pt-type">string</td><td><span class="pt-opt">optional</span></td><td>Page ID to create reports under</td></tr>
32309 <tr><td class="pt-name">schedule_auto_post</td><td class="pt-type">object</td><td><span class="pt-opt">optional</span></td><td>Map of schedule UUID → boolean for auto-posting on webhook trigger</td></tr>
32310 </table>
32311 <details class="schema"><summary>Response schema</summary>
32312<div class="schema-block">{ "ok": true }</div></details>
32313 <p class="curl-heading">Example</p>
32314 <div class="curl-wrap">
32315 <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
32316 -H "Authorization: Bearer $SLOC_API_KEY" \
32317 -H "Content-Type: application/json" \
32318 -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
32319 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
32320 <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
32321 </div>
32322 </div>
32323 </div>
32324
32325 <div class="ep-card">
32326 <div class="ep-header">
32327 <span class="method post">POST</span>
32328 <span class="ep-path">/api/confluence/test</span>
32329 <span class="auth-badge protected">Protected</span>
32330 <span class="ep-desc">Test Confluence connection</span>
32331 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32332 </div>
32333 <div class="ep-body">
32334 <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
32335 <details class="schema"><summary>Response schema</summary>
32336<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
32337 <p class="curl-heading">Example</p>
32338 <div class="curl-wrap">
32339 <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
32340 -H "Authorization: Bearer $SLOC_API_KEY" \
32341 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
32342 <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
32343 </div>
32344 </div>
32345 </div>
32346
32347 <div class="ep-card">
32348 <div class="ep-header">
32349 <span class="method post">POST</span>
32350 <span class="ep-path">/api/confluence/post</span>
32351 <span class="auth-badge protected">Protected</span>
32352 <span class="ep-desc">Publish a scan report to Confluence</span>
32353 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32354 </div>
32355 <div class="ep-body">
32356 <p class="ep-desc-full">Creates or updates a Confluence page containing the SLOC metrics for the specified run. Requires Confluence to be configured via <code>POST /api/confluence/config</code>.</p>
32357 <p class="params-heading">Request Body (application/json)</p>
32358 <table class="params">
32359 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32360 <tr><td class="pt-name">run_id</td><td class="pt-type">string (UUID)</td><td><span class="pt-req">required</span></td><td>Run whose metrics to publish</td></tr>
32361 <tr><td class="pt-name">page_title</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Title for the Confluence page</td></tr>
32362 <tr><td class="pt-name">report_url</td><td class="pt-type">string</td><td><span class="pt-opt">optional</span></td><td>URL to the HTML report, included as a link in the page</td></tr>
32363 </table>
32364 <details class="schema"><summary>Response schema</summary>
32365<div class="schema-block">// 200 OK
32366{ "ok": true, "page_id": string }
32367
32368// 400 / 502 on error
32369{ "ok": false, "error": string }</div></details>
32370 <p class="curl-heading">Example</p>
32371 <div class="curl-wrap">
32372 <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
32373 -H "Authorization: Bearer $SLOC_API_KEY" \
32374 -H "Content-Type: application/json" \
32375 -d '{"run_id":"<uuid>","page_title":"SLOC Report 2025-05-10"}' \
32376 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
32377 <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
32378 </div>
32379 </div>
32380 </div>
32381
32382 <div class="ep-card">
32383 <div class="ep-header">
32384 <span class="method get">GET</span>
32385 <span class="ep-path">/api/confluence/wiki-markup</span>
32386 <span class="auth-badge protected">Protected</span>
32387 <span class="ep-desc">Get Confluence wiki markup for a run</span>
32388 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32389 </div>
32390 <div class="ep-body">
32391 <p class="ep-desc-full">Returns the Confluence Storage Format (XHTML) markup that would be posted for the given run, so you can preview or extend it before publishing.</p>
32392 <p class="params-heading">Query Parameters</p>
32393 <table class="params">
32394 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32395 <tr><td class="pt-name">run_id</td><td class="pt-type">string (UUID)</td><td><span class="pt-req">required</span></td><td>Run to generate markup for</td></tr>
32396 </table>
32397 <p class="curl-heading">Example</p>
32398 <div class="curl-wrap">
32399 <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32400 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=<uuid>"</pre>
32401 <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
32402 </div>
32403 </div>
32404 </div>
32405 </div>
32406
32407 <!-- Authentication -->
32408 <div class="section">
32409 <h2 class="section-title">Authentication</h2>
32410 <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
32411
32412 <div class="ep-card">
32413 <div class="ep-header">
32414 <span class="method get">GET</span>
32415 <span class="ep-path">/auth/login</span>
32416 <span class="auth-badge public">Public</span>
32417 <span class="ep-desc">Login page</span>
32418 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32419 </div>
32420 <div class="ep-body">
32421 <p class="ep-desc-full">Returns the HTML login form. Redirects to <code>/</code> immediately when no API key is configured on the server.</p>
32422 <p class="params-heading">Query Parameters</p>
32423 <table class="params">
32424 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32425 <tr><td class="pt-name">next</td><td class="pt-type">string</td><td><span class="pt-opt">optional</span></td><td>URL to redirect to after a successful login</td></tr>
32426 <tr><td class="pt-name">error</td><td class="pt-type">string</td><td><span class="pt-opt">optional</span></td><td>Pass <code>1</code> to display an invalid-credentials error</td></tr>
32427 </table>
32428 </div>
32429 </div>
32430
32431 <div class="ep-card">
32432 <div class="ep-header">
32433 <span class="method post">POST</span>
32434 <span class="ep-path">/auth/login</span>
32435 <span class="auth-badge public">Public</span>
32436 <span class="ep-desc">Submit credentials and get a session cookie</span>
32437 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32438 </div>
32439 <div class="ep-body">
32440 <p class="ep-desc-full">Validates the submitted API key and sets a <code>sloc_session</code> cookie on success. The cookie is <code>HttpOnly; SameSite=Strict</code> and is accepted by all protected endpoints in lieu of an <code>Authorization</code> or <code>X-API-Key</code> header.</p>
32441 <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
32442 <table class="params">
32443 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32444 <tr><td class="pt-name">key</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>API key to validate</td></tr>
32445 <tr><td class="pt-name">next</td><td class="pt-type">string</td><td><span class="pt-opt">optional</span></td><td>Redirect target on success (must start with <code>/</code>)</td></tr>
32446 </table>
32447 <p class="curl-heading">Example</p>
32448 <div class="curl-wrap">
32449 <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
32450 -d "key=$SLOC_API_KEY&next=/" \
32451 <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
32452 <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
32453 </div>
32454 </div>
32455 </div>
32456 </div>
32457
32458 <!-- Coverage Suggestion -->
32459 <div class="section">
32460 <h2 class="section-title">Coverage Suggestion</h2>
32461
32462 <div class="ep-card">
32463 <div class="ep-header">
32464 <span class="method get">GET</span>
32465 <span class="ep-path">/api/suggest-coverage</span>
32466 <span class="auth-badge protected">Protected</span>
32467 <span class="ep-desc">Auto-detect a coverage file for a project root</span>
32468 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32469 </div>
32470 <div class="ep-body">
32471 <p class="ep-desc-full">Scans a local project root for common coverage report files (LCOV, Cobertura XML, JaCoCo XML, coverage.py JSON) and returns the first one found, along with a hint for how to generate it if not present.</p>
32472 <p class="params-heading">Query Parameters</p>
32473 <table class="params">
32474 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32475 <tr><td class="pt-name">path</td><td class="pt-type">string</td><td><span class="pt-opt">optional</span></td><td>Absolute path to the project root to inspect</td></tr>
32476 </table>
32477 <details class="schema"><summary>Response schema</summary>
32478<div class="schema-block">{
32479 "found": string | null, // absolute path to the coverage file, if detected
32480 "tool": string | null, // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
32481 "hint": string | null // shell command to generate coverage if not found
32482}</div></details>
32483 <p class="curl-heading">Example</p>
32484 <div class="curl-wrap">
32485 <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32486 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
32487 <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
32488 </div>
32489 </div>
32490 </div>
32491 </div>
32492
32493 </div>
32494
32495 <footer class="site-footer">
32496 local code analysis - metrics, history and reports
32497 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
32498 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
32499 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
32500 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
32501 · <a href="/api-docs" rel="noopener">REST API</a>
32502 </footer>
32503
32504 <script nonce="{{ csp_nonce }}">
32505 (function () {
32506 var base = window.location.origin;
32507 document.getElementById('base-url').textContent = base;
32508 document.querySelectorAll('.base-url-slot').forEach(function (el) {
32509 el.textContent = base;
32510 });
32511
32512 document.querySelectorAll('.ep-header').forEach(function (hdr) {
32513 hdr.addEventListener('click', function () {
32514 hdr.closest('.ep-card').classList.toggle('open');
32515 });
32516 });
32517
32518 document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
32519 btn.addEventListener('click', function () {
32520 var targetId = btn.dataset.target;
32521 var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
32522 if (!pre) return;
32523 navigator.clipboard.writeText(pre.textContent).then(function () {
32524 btn.textContent = 'Copied!';
32525 btn.classList.add('copied');
32526 setTimeout(function () {
32527 btn.textContent = 'Copy';
32528 btn.classList.remove('copied');
32529 }, 2000);
32530 });
32531 });
32532 });
32533
32534 var storageKey = 'oxide-sloc-theme';
32535 try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
32536 var themeBtn = document.getElementById('theme-toggle');
32537 if (themeBtn) {
32538 themeBtn.addEventListener('click', function () {
32539 var dark = document.body.classList.toggle('dark-theme');
32540 try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
32541 });
32542 }
32543 (function() {
32544 var S=[{n:'Classic',a:'#b85d33',b:'#7a371b'},{n:'Navy',a:'#283790',b:'#1e1e24'},{n:'Ember',a:'#ce5d3d',b:'#1e1e24'},{n:'Ocean',a:'#1f439b',b:'#1e1e24'},{n:'Royal',a:'#003184',b:'#1e1e24'}];
32545 function ap(s){document.documentElement.style.setProperty('--nav',s.a);document.documentElement.style.setProperty('--nav-2',s.b);try{localStorage.setItem('sloc-ns',JSON.stringify(s));}catch(e){}document.querySelectorAll('.scheme-swatch').forEach(function(x){x.classList.toggle('active',x.dataset.n===s.n);});}
32546 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
32547 var btn=document.getElementById('settings-btn');if(!btn)return;
32548 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
32549 m.innerHTML='<div class="settings-modal-header"><span>Appearance</span><button type="button" class="settings-close" id="settings-close" aria-label="Close"><svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="settings-modal-body"><div class="settings-modal-label">Navigation color scheme</div><div class="scheme-grid" id="scheme-grid"></div><div style="margin-top:12px;border-top:1px solid var(--line);padding-top:12px;"><div class="settings-modal-label" style="margin-bottom:8px;">Timestamp timezone</div><select class="tz-select" id="tz-select"><option value="America/Los_Angeles">Pacific (PT)</option><option value="America/Denver">Mountain (MT)</option><option value="America/Chicago">Central (CT)</option><option value="America/New_York">Eastern (ET)</option><option value="America/Anchorage">Alaska (AT)</option><option value="Pacific/Honolulu">Hawaii (HT)</option></select></div></div>';
32550 document.body.appendChild(m);
32551 var g=document.getElementById('scheme-grid');
32552 if(g)S.forEach(function(s){var el=document.createElement('button');el.type='button';el.className='scheme-swatch';el.dataset.n=s.n;el.title=s.n;var p=document.createElement('div');p.className='scheme-preview';p.style.background='linear-gradient(135deg,'+s.a+','+s.b+')';var l=document.createElement('span');l.className='scheme-label';l.textContent=s.n;el.appendChild(p);el.appendChild(l);try{var c=JSON.parse(localStorage.getItem('sloc-ns'));if(c&&c.n===s.n)el.classList.add('active');}catch(e){}el.addEventListener('click',function(){ap(s);});g.appendChild(el);});
32553 var cl=document.getElementById('settings-close');
32554 window.tzAbbr=function(z){return{'America/Los_Angeles':'PT','America/Denver':'MT','America/Chicago':'CT','America/New_York':'ET','America/Anchorage':'AT','Pacific/Honolulu':'HT'}[z]||'PT';};window.tzCity=function(z){return{'America/Los_Angeles':'Los Angeles','America/Denver':'Denver','America/Chicago':'Chicago','America/New_York':'New York','America/Anchorage':'Anchorage','Pacific/Honolulu':'Honolulu'}[z]||'';};window.tzOffset=function(z){var r='';try{var p=new Intl.DateTimeFormat('en-US',{timeZone:z,timeZoneName:'longOffset'}).formatToParts(new Date());p.forEach(function(x){if(x.type==='timeZoneName')r=x.value.replace('GMT','UTC');});}catch(e){}return r;};window.tf24=function(){try{return localStorage.getItem('sloc-tf')!=='12';}catch(e){return true;}};window.fmtTz=function(ms,tz){var d=new Date(ms);if(isNaN(d.getTime()))return'';var h24=window.tf24();try{var pts=new Intl.DateTimeFormat('en-US',{timeZone:tz,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:!h24}).formatToParts(d);var v={};pts.forEach(function(p){v[p.type]=p.value;});var t=v.hour+':'+v.minute;if(!h24&&v.dayPeriod)t+=' '+v.dayPeriod;return v.year+'-'+v.month+'-'+v.day+' '+t+' '+window.tzAbbr(tz);}catch(e){return'';}};window.enhanceTzOptions=function(sel){if(!sel)return;Array.prototype.forEach.call(sel.options,function(o){var base=o.textContent.split(' - ')[0];var city=window.tzCity(o.value);var off=window.tzOffset(o.value);o.textContent=base+(city?' - '+city:'')+(off?' - '+off:'');});};window.applyTz=function(tz){try{localStorage.setItem('sloc-tz',tz);}catch(e){}document.querySelectorAll('[data-utc-ms]').forEach(function(el){var ms=parseInt(el.getAttribute('data-utc-ms'),10);if(!isNaN(ms))el.textContent=window.fmtTz(ms,tz);});};window.applyTf=function(tf){try{localStorage.setItem('sloc-tf',tf);}catch(e){}var z;try{z=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){z='America/Los_Angeles';}window.applyTz(z);};var tzSel=document.getElementById('tz-select');window.enhanceTzOptions(tzSel);var storedTz;try{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){storedTz='America/Los_Angeles';}if(tzSel){tzSel.value=storedTz;tzSel.addEventListener('change',function(){window.applyTz(this.value);});}window.applyTz(storedTz);(function(){var tzp=document.getElementById('tz-select');if(!tzp||document.getElementById('tf-select')||!tzp.parentNode)return;var tw=document.createElement('div');tw.style.marginTop='10px';var tl=document.createElement('div');tl.className='settings-modal-label';tl.style.marginBottom='8px';tl.textContent='Time format';var tfSel=document.createElement('select');tfSel.className='tz-select';tfSel.id='tf-select';tfSel.innerHTML='<option value="24">24-hour (14:30)</option><option value="12">12-hour (2:30 PM)</option>';tw.appendChild(tl);tw.appendChild(tfSel);tzp.parentNode.appendChild(tw);var storedTf;try{storedTf=localStorage.getItem('sloc-tf')||'24';}catch(e){storedTf='24';}tfSel.value=storedTf;tfSel.addEventListener('change',function(){window.applyTf(this.value);});})();
32555 btn.addEventListener('click',function(e){e.stopPropagation();var r=btn.getBoundingClientRect();m.style.top=(r.bottom+6)+'px';m.style.right=(window.innerWidth-r.right)+'px';m.classList.toggle('open');});
32556 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
32557 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
32558 })();
32559 (function randomizeWatermarks() {
32560 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
32561 if (!wms.length) return;
32562 var placed = [];
32563 function tooClose(top, left) {
32564 for (var i = 0; i < placed.length; i++) {
32565 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
32566 if (dt < 16 && dl < 12) return true;
32567 }
32568 return false;
32569 }
32570 function pick(leftBand) {
32571 for (var attempt = 0; attempt < 50; attempt++) {
32572 var top = Math.random() * 88 + 2;
32573 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
32574 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
32575 }
32576 var top = Math.random() * 88 + 2;
32577 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
32578 placed.push([top, left]); return [top, left];
32579 }
32580 var half = Math.floor(wms.length / 2);
32581 wms.forEach(function (img, i) {
32582 var pos = pick(i < half);
32583 var size = Math.floor(Math.random() * 100 + 120);
32584 var rot = (Math.random() * 360).toFixed(1);
32585 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
32586 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;
32587 });
32588 })();
32589 (function spawnCodeParticles() {
32590 var container = document.getElementById('code-particles');
32591 if (!container) return;
32592 var snippets = [
32593 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
32594 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
32595 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
32596 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
32597 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
32598 ];
32599 var count = 38;
32600 for (var i = 0; i < count; i++) {
32601 (function(idx) {
32602 var el = document.createElement('span');
32603 el.className = 'code-particle';
32604 el.textContent = snippets[idx % snippets.length];
32605 var left = Math.random() * 94 + 2;
32606 var top = Math.random() * 88 + 6;
32607 var dur = (Math.random() * 10 + 9).toFixed(1);
32608 var delay = (Math.random() * 18).toFixed(1);
32609 var rot = (Math.random() * 26 - 13).toFixed(1);
32610 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
32611 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
32612 container.appendChild(el);
32613 })(i);
32614 }
32615 })();
32616 }());
32617 </script>
32618</body>
32619</html>
32620"##,
32621 ext = "html"
32622)]
32623struct ApiDocsTemplate {
32624 has_api_key: bool,
32625 csp_nonce: String,
32626 version: &'static str,
32627}
32628
32629#[cfg(test)]
32630mod form_config_tests {
32631 use super::*;
32632 use sloc_config::{
32633 BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy, MixedLinePolicy,
32634 };
32635
32636 fn blank_form() -> AnalyzeForm {
32637 AnalyzeForm {
32638 path: ".".to_string(),
32639 git_repo: None,
32640 git_ref: None,
32641 mixed_line_policy: None,
32642 python_docstrings_as_comments: None,
32643 generated_file_detection: None,
32644 minified_file_detection: None,
32645 vendor_directory_detection: None,
32646 include_lockfiles: None,
32647 binary_file_behavior: None,
32648 output_dir: None,
32649 report_title: None,
32650 report_header_footer: None,
32651 include_globs: None,
32652 exclude_globs: None,
32653 submodule_breakdown: None,
32654 coverage_file: None,
32655 continuation_line_policy: None,
32656 blank_in_block_comment_policy: None,
32657 count_compiler_directives: None,
32658 style_col_threshold: None,
32659 style_analysis_enabled: None,
32660 style_score_threshold: None,
32661 style_lang_scope: None,
32662 cocomo_mode: None,
32663 complexity_alert: None,
32664 exclude_duplicates: None,
32665 activity_window: None,
32666 }
32667 }
32668
32669 fn apply(form: &AnalyzeForm) -> sloc_config::AppConfig {
32670 let mut cfg = sloc_config::AppConfig::default();
32671 apply_form_to_config(&mut cfg, form);
32672 cfg
32673 }
32674
32675 #[test]
32678 fn extract_long_commit_picks_super_repo_by_short_prefix() {
32679 let dir = tempfile::tempdir().unwrap();
32683 let path = dir.path().join("result.json");
32684 let body = r#"{
32685 "submodules": [
32686 { "git_commit_long": "aaaa111122223333444455556666777788889999" },
32687 { "git_commit_long": null }
32688 ],
32689 "git_commit_short": "4c2cd9b",
32690 "git_commit_long": "4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b"
32691}"#;
32692 std::fs::write(&path, body).unwrap();
32693 assert_eq!(
32694 super::extract_long_commit_from_json(&path, "4c2cd9b").as_deref(),
32695 Some("4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b")
32696 );
32697 assert_eq!(super::extract_long_commit_from_json(&path, "deadbee"), None);
32699 assert_eq!(super::extract_long_commit_from_json(&path, ""), None);
32700 }
32701
32702 #[test]
32703 fn activity_window_defaults_on_when_field_blank() {
32704 let cfg = apply(&blank_form());
32706 assert_eq!(cfg.analysis.activity_window_days, Some(90));
32707 }
32708
32709 #[test]
32710 fn activity_window_override_sets_days() {
32711 let mut form = blank_form();
32712 form.activity_window = Some("30".to_string());
32713 let cfg = apply(&form);
32714 assert_eq!(cfg.analysis.activity_window_days, Some(30));
32715 }
32716
32717 #[test]
32718 fn activity_window_zero_disables() {
32719 let mut form = blank_form();
32721 form.activity_window = Some("0".to_string());
32722 let cfg = apply(&form);
32723 assert_eq!(cfg.analysis.activity_window_days, Some(0));
32724 }
32725
32726 #[test]
32729 fn python_docstrings_false_when_unchecked() {
32730 let cfg = apply(&blank_form());
32732 assert!(
32733 !cfg.analysis.python_docstrings_as_comments,
32734 "absent python_docstrings_as_comments must map to false"
32735 );
32736 }
32737
32738 #[test]
32739 fn python_docstrings_true_when_checked() {
32740 let mut form = blank_form();
32742 form.python_docstrings_as_comments = Some("on".to_string());
32743 let cfg = apply(&form);
32744 assert!(cfg.analysis.python_docstrings_as_comments);
32745 }
32746
32747 #[test]
32748 fn python_docstrings_true_for_any_non_none_value() {
32749 let mut form = blank_form();
32751 form.python_docstrings_as_comments = Some("true".to_string());
32752 assert!(apply(&form).analysis.python_docstrings_as_comments);
32753 }
32754
32755 #[test]
32758 fn submodule_breakdown_false_when_unchecked() {
32759 let cfg = apply(&blank_form());
32760 assert!(
32761 !cfg.discovery.submodule_breakdown,
32762 "absent submodule_breakdown must map to false"
32763 );
32764 }
32765
32766 #[test]
32767 fn submodule_breakdown_true_when_value_enabled() {
32768 let mut form = blank_form();
32769 form.submodule_breakdown = Some("enabled".to_string());
32770 assert!(apply(&form).discovery.submodule_breakdown);
32771 }
32772
32773 #[test]
32774 fn submodule_breakdown_false_for_wrong_value() {
32775 let mut form = blank_form();
32777 form.submodule_breakdown = Some("on".to_string());
32778 assert!(
32779 !apply(&form).discovery.submodule_breakdown,
32780 "submodule_breakdown only becomes true for the exact value 'enabled'"
32781 );
32782 }
32783
32784 #[test]
32787 fn generated_detection_true_when_enabled() {
32788 let mut form = blank_form();
32789 form.generated_file_detection = Some("enabled".to_string());
32790 assert!(apply(&form).analysis.generated_file_detection);
32791 }
32792
32793 #[test]
32794 fn generated_detection_false_when_disabled() {
32795 let mut form = blank_form();
32796 form.generated_file_detection = Some("disabled".to_string());
32797 assert!(!apply(&form).analysis.generated_file_detection);
32798 }
32799
32800 #[test]
32801 fn generated_detection_true_when_absent() {
32802 assert!(
32804 apply(&blank_form()).analysis.generated_file_detection,
32805 "absent field must default to true (detection on)"
32806 );
32807 }
32808
32809 #[test]
32812 fn minified_detection_false_when_disabled() {
32813 let mut form = blank_form();
32814 form.minified_file_detection = Some("disabled".to_string());
32815 assert!(!apply(&form).analysis.minified_file_detection);
32816 }
32817
32818 #[test]
32819 fn minified_detection_true_when_enabled() {
32820 let mut form = blank_form();
32821 form.minified_file_detection = Some("enabled".to_string());
32822 assert!(apply(&form).analysis.minified_file_detection);
32823 }
32824
32825 #[test]
32826 fn minified_detection_true_when_absent() {
32827 assert!(apply(&blank_form()).analysis.minified_file_detection);
32828 }
32829
32830 #[test]
32833 fn vendor_detection_false_when_disabled() {
32834 let mut form = blank_form();
32835 form.vendor_directory_detection = Some("disabled".to_string());
32836 assert!(!apply(&form).analysis.vendor_directory_detection);
32837 }
32838
32839 #[test]
32840 fn vendor_detection_true_when_enabled() {
32841 let mut form = blank_form();
32842 form.vendor_directory_detection = Some("enabled".to_string());
32843 assert!(apply(&form).analysis.vendor_directory_detection);
32844 }
32845
32846 #[test]
32847 fn vendor_detection_true_when_absent() {
32848 assert!(apply(&blank_form()).analysis.vendor_directory_detection);
32849 }
32850
32851 #[test]
32854 fn lockfiles_false_when_absent() {
32855 assert!(!apply(&blank_form()).analysis.include_lockfiles);
32857 }
32858
32859 #[test]
32860 fn lockfiles_false_when_disabled() {
32861 let mut form = blank_form();
32862 form.include_lockfiles = Some("disabled".to_string());
32863 assert!(!apply(&form).analysis.include_lockfiles);
32864 }
32865
32866 #[test]
32867 fn lockfiles_true_when_enabled() {
32868 let mut form = blank_form();
32869 form.include_lockfiles = Some("enabled".to_string());
32870 assert!(apply(&form).analysis.include_lockfiles);
32871 }
32872
32873 #[test]
32876 fn compiler_directives_true_when_absent() {
32877 assert!(
32878 apply(&blank_form()).analysis.count_compiler_directives,
32879 "absent count_compiler_directives must default to true"
32880 );
32881 }
32882
32883 #[test]
32884 fn compiler_directives_true_when_enabled() {
32885 let mut form = blank_form();
32886 form.count_compiler_directives = Some("enabled".to_string());
32887 assert!(apply(&form).analysis.count_compiler_directives);
32888 }
32889
32890 #[test]
32891 fn compiler_directives_false_when_disabled() {
32892 let mut form = blank_form();
32893 form.count_compiler_directives = Some("disabled".to_string());
32894 assert!(!apply(&form).analysis.count_compiler_directives);
32895 }
32896
32897 #[test]
32900 fn mixed_policy_unchanged_when_absent() {
32901 assert_eq!(
32903 apply(&blank_form()).analysis.mixed_line_policy,
32904 MixedLinePolicy::CodeOnly
32905 );
32906 }
32907
32908 #[test]
32909 fn mixed_policy_code_only() {
32910 let mut form = blank_form();
32911 form.mixed_line_policy = Some(MixedLinePolicy::CodeOnly);
32912 assert_eq!(
32913 apply(&form).analysis.mixed_line_policy,
32914 MixedLinePolicy::CodeOnly
32915 );
32916 }
32917
32918 #[test]
32919 fn mixed_policy_code_and_comment() {
32920 let mut form = blank_form();
32921 form.mixed_line_policy = Some(MixedLinePolicy::CodeAndComment);
32922 assert_eq!(
32923 apply(&form).analysis.mixed_line_policy,
32924 MixedLinePolicy::CodeAndComment
32925 );
32926 }
32927
32928 #[test]
32929 fn mixed_policy_comment_only() {
32930 let mut form = blank_form();
32931 form.mixed_line_policy = Some(MixedLinePolicy::CommentOnly);
32932 assert_eq!(
32933 apply(&form).analysis.mixed_line_policy,
32934 MixedLinePolicy::CommentOnly
32935 );
32936 }
32937
32938 #[test]
32939 fn mixed_policy_separate_mixed_category() {
32940 let mut form = blank_form();
32941 form.mixed_line_policy = Some(MixedLinePolicy::SeparateMixedCategory);
32942 assert_eq!(
32943 apply(&form).analysis.mixed_line_policy,
32944 MixedLinePolicy::SeparateMixedCategory
32945 );
32946 }
32947
32948 #[test]
32951 fn binary_behavior_skip_when_absent() {
32952 assert_eq!(
32953 apply(&blank_form()).analysis.binary_file_behavior,
32954 BinaryFileBehavior::Skip
32955 );
32956 }
32957
32958 #[test]
32959 fn binary_behavior_skip() {
32960 let mut form = blank_form();
32961 form.binary_file_behavior = Some(BinaryFileBehavior::Skip);
32962 assert_eq!(
32963 apply(&form).analysis.binary_file_behavior,
32964 BinaryFileBehavior::Skip
32965 );
32966 }
32967
32968 #[test]
32969 fn binary_behavior_fail() {
32970 let mut form = blank_form();
32971 form.binary_file_behavior = Some(BinaryFileBehavior::Fail);
32972 assert_eq!(
32973 apply(&form).analysis.binary_file_behavior,
32974 BinaryFileBehavior::Fail
32975 );
32976 }
32977
32978 #[test]
32981 fn continuation_policy_each_physical_when_absent() {
32982 assert_eq!(
32983 apply(&blank_form()).analysis.continuation_line_policy,
32984 ContinuationLinePolicy::EachPhysicalLine
32985 );
32986 }
32987
32988 #[test]
32989 fn continuation_policy_collapse_to_logical() {
32990 let mut form = blank_form();
32991 form.continuation_line_policy = Some(ContinuationLinePolicy::CollapseToLogical);
32992 assert_eq!(
32993 apply(&form).analysis.continuation_line_policy,
32994 ContinuationLinePolicy::CollapseToLogical
32995 );
32996 }
32997
32998 #[test]
33001 fn blank_in_block_comment_count_as_comment_when_absent() {
33002 assert_eq!(
33003 apply(&blank_form()).analysis.blank_in_block_comment_policy,
33004 BlankInBlockCommentPolicy::CountAsComment
33005 );
33006 }
33007
33008 #[test]
33009 fn blank_in_block_comment_count_as_blank() {
33010 let mut form = blank_form();
33011 form.blank_in_block_comment_policy = Some(BlankInBlockCommentPolicy::CountAsBlank);
33012 assert_eq!(
33013 apply(&form).analysis.blank_in_block_comment_policy,
33014 BlankInBlockCommentPolicy::CountAsBlank
33015 );
33016 }
33017
33018 #[test]
33021 fn style_threshold_80() {
33022 let mut form = blank_form();
33023 form.style_col_threshold = Some("80".to_string());
33024 assert_eq!(apply(&form).analysis.style_col_threshold, 80);
33025 }
33026
33027 #[test]
33028 fn style_threshold_100() {
33029 let mut form = blank_form();
33030 form.style_col_threshold = Some("100".to_string());
33031 assert_eq!(apply(&form).analysis.style_col_threshold, 100);
33032 }
33033
33034 #[test]
33035 fn style_threshold_120() {
33036 let mut form = blank_form();
33037 form.style_col_threshold = Some("120".to_string());
33038 assert_eq!(apply(&form).analysis.style_col_threshold, 120);
33039 }
33040
33041 #[test]
33042 fn style_threshold_invalid_value_leaves_default() {
33043 let mut cfg = sloc_config::AppConfig::default();
33045 let mut form = blank_form();
33046 form.style_col_threshold = Some("42".to_string());
33047 apply_form_to_config(&mut cfg, &form);
33048 assert_eq!(
33049 cfg.analysis.style_col_threshold, 80,
33050 "invalid threshold must not change config"
33051 );
33052 }
33053
33054 #[test]
33055 fn style_threshold_non_numeric_leaves_default() {
33056 let mut cfg = sloc_config::AppConfig::default();
33057 let mut form = blank_form();
33058 form.style_col_threshold = Some("large".to_string());
33059 apply_form_to_config(&mut cfg, &form);
33060 assert_eq!(cfg.analysis.style_col_threshold, 80);
33061 }
33062
33063 #[test]
33064 fn style_threshold_zero_leaves_default() {
33065 let mut cfg = sloc_config::AppConfig::default();
33066 let mut form = blank_form();
33067 form.style_col_threshold = Some("0".to_string());
33068 apply_form_to_config(&mut cfg, &form);
33069 assert_eq!(cfg.analysis.style_col_threshold, 80);
33070 }
33071
33072 #[test]
33073 fn style_threshold_absent_leaves_default() {
33074 assert_eq!(apply(&blank_form()).analysis.style_col_threshold, 80);
33075 }
33076
33077 #[test]
33080 fn style_score_threshold_zero_when_absent() {
33081 assert_eq!(apply(&blank_form()).analysis.style_score_threshold, 0);
33082 }
33083
33084 #[test]
33085 fn style_score_threshold_set_to_valid_value() {
33086 let mut form = blank_form();
33087 form.style_score_threshold = Some("70".to_string());
33088 assert_eq!(apply(&form).analysis.style_score_threshold, 70);
33089 }
33090
33091 #[test]
33092 fn style_score_threshold_clamps_to_100_when_over() {
33093 let mut form = blank_form();
33095 form.style_score_threshold = Some("200".to_string());
33096 assert_eq!(
33097 apply(&form).analysis.style_score_threshold,
33098 100,
33099 "style_score_threshold must be clamped to 100 when the submitted value exceeds it"
33100 );
33101 }
33102
33103 #[test]
33106 fn coverage_file_none_when_absent() {
33107 assert!(apply(&blank_form()).analysis.coverage_file.is_none());
33108 }
33109
33110 #[test]
33111 fn coverage_file_none_when_whitespace_only() {
33112 let mut form = blank_form();
33113 form.coverage_file = Some(" ".to_string());
33114 assert!(
33115 apply(&form).analysis.coverage_file.is_none(),
33116 "whitespace-only coverage_file must be treated as None"
33117 );
33118 }
33119
33120 #[test]
33121 fn coverage_file_set_when_non_empty() {
33122 let mut form = blank_form();
33123 form.coverage_file = Some("coverage/lcov.info".to_string());
33124 assert_eq!(
33125 apply(&form).analysis.coverage_file,
33126 Some(std::path::PathBuf::from("coverage/lcov.info"))
33127 );
33128 }
33129
33130 #[test]
33131 fn coverage_file_trims_whitespace() {
33132 let mut form = blank_form();
33133 form.coverage_file = Some(" coverage/lcov.info ".to_string());
33134 assert_eq!(
33135 apply(&form).analysis.coverage_file,
33136 Some(std::path::PathBuf::from("coverage/lcov.info"))
33137 );
33138 }
33139
33140 #[test]
33143 fn report_title_unchanged_when_absent() {
33144 let original = sloc_config::AppConfig::default().reporting.report_title;
33145 assert_eq!(apply(&blank_form()).reporting.report_title, original);
33146 }
33147
33148 #[test]
33149 fn report_title_unchanged_when_whitespace_only() {
33150 let original = sloc_config::AppConfig::default().reporting.report_title;
33151 let mut form = blank_form();
33152 form.report_title = Some(" ".to_string());
33153 assert_eq!(
33154 apply(&form).reporting.report_title,
33155 original,
33156 "whitespace-only title must not overwrite the default"
33157 );
33158 }
33159
33160 #[test]
33161 fn report_title_updated_and_trimmed() {
33162 let mut form = blank_form();
33163 form.report_title = Some(" My Project ".to_string());
33164 assert_eq!(apply(&form).reporting.report_title, "My Project");
33165 }
33166
33167 #[test]
33170 fn header_footer_none_when_absent() {
33171 assert!(apply(&blank_form())
33172 .reporting
33173 .report_header_footer
33174 .is_none());
33175 }
33176
33177 #[test]
33178 fn header_footer_none_when_whitespace_only() {
33179 let mut form = blank_form();
33180 form.report_header_footer = Some(" ".to_string());
33181 assert!(apply(&form).reporting.report_header_footer.is_none());
33182 }
33183
33184 #[test]
33185 fn header_footer_set_and_trimmed() {
33186 let mut form = blank_form();
33187 form.report_header_footer = Some(" Confidential — Internal Use ".to_string());
33188 assert_eq!(
33189 apply(&form).reporting.report_header_footer,
33190 Some("Confidential — Internal Use".to_string())
33191 );
33192 }
33193
33194 #[test]
33197 fn include_globs_empty_when_absent() {
33198 assert!(apply(&blank_form()).discovery.include_globs.is_empty());
33199 }
33200
33201 #[test]
33202 fn include_globs_newline_separated() {
33203 let mut form = blank_form();
33204 form.include_globs = Some("src/**/*.rs\ntests/**/*.rs".to_string());
33205 assert_eq!(
33206 apply(&form).discovery.include_globs,
33207 vec!["src/**/*.rs", "tests/**/*.rs"]
33208 );
33209 }
33210
33211 #[test]
33212 fn exclude_globs_comma_separated() {
33213 let mut form = blank_form();
33214 form.exclude_globs = Some("vendor/**,node_modules/**".to_string());
33215 assert_eq!(
33216 apply(&form).discovery.exclude_globs,
33217 vec!["vendor/**", "node_modules/**"]
33218 );
33219 }
33220
33221 #[test]
33222 fn globs_mixed_separators() {
33223 let mut form = blank_form();
33224 form.exclude_globs = Some("a/**\nb/**,c/**".to_string());
33225 assert_eq!(
33226 apply(&form).discovery.exclude_globs,
33227 vec!["a/**", "b/**", "c/**"]
33228 );
33229 }
33230
33231 #[test]
33234 fn split_patterns_none_is_empty() {
33235 assert!(split_patterns(None).is_empty());
33236 }
33237
33238 #[test]
33239 fn split_patterns_empty_string_is_empty() {
33240 assert!(split_patterns(Some("")).is_empty());
33241 }
33242
33243 #[test]
33244 fn split_patterns_whitespace_only_is_empty() {
33245 assert!(split_patterns(Some(" \n \n ")).is_empty());
33246 }
33247
33248 #[test]
33249 fn split_patterns_newlines() {
33250 assert_eq!(
33251 split_patterns(Some("a/**\nb/**\nc/**")),
33252 vec!["a/**", "b/**", "c/**"]
33253 );
33254 }
33255
33256 #[test]
33257 fn split_patterns_commas() {
33258 assert_eq!(
33259 split_patterns(Some("a/**,b/**,c/**")),
33260 vec!["a/**", "b/**", "c/**"]
33261 );
33262 }
33263
33264 #[test]
33265 fn split_patterns_mixed() {
33266 assert_eq!(
33267 split_patterns(Some("a/**\nb/**,c/**")),
33268 vec!["a/**", "b/**", "c/**"]
33269 );
33270 }
33271
33272 #[test]
33273 fn split_patterns_trims_whitespace() {
33274 assert_eq!(
33275 split_patterns(Some(" a/** \n b/** ")),
33276 vec!["a/**", "b/**"]
33277 );
33278 }
33279
33280 #[test]
33281 fn split_patterns_filters_empty_entries() {
33282 assert_eq!(split_patterns(Some(",\n,,a/**,,\n")), vec!["a/**"]);
33283 }
33284
33285 #[test]
33286 fn split_patterns_single_entry() {
33287 assert_eq!(split_patterns(Some("src/**")), vec!["src/**"]);
33288 }
33289}
33290
33291#[cfg(test)]
33292mod utility_tests {
33293 use super::*;
33294 use std::net::IpAddr;
33295 use std::time::Duration;
33296
33297 #[test]
33300 fn sanitize_simple_name() {
33301 assert_eq!(sanitize_project_label("myrepo"), "myrepo");
33302 }
33303
33304 #[test]
33305 fn sanitize_uppercased_lowercased() {
33306 assert_eq!(sanitize_project_label("MyRepo"), "myrepo");
33307 }
33308
33309 #[test]
33310 fn sanitize_path_extracts_filename() {
33311 assert_eq!(
33312 sanitize_project_label("/home/user/my-project"),
33313 "my-project"
33314 );
33315 }
33316
33317 #[test]
33318 fn sanitize_path_uses_last_component() {
33319 assert_eq!(sanitize_project_label("/a/b/c/d"), "d");
33320 }
33321
33322 #[test]
33323 fn sanitize_spaces_become_hyphens() {
33324 assert_eq!(sanitize_project_label("my project"), "my-project");
33325 }
33326
33327 #[test]
33328 fn sanitize_non_ascii_become_hyphens() {
33329 assert_eq!(sanitize_project_label("proj\u{00e9}ct"), "proj-ct");
33330 }
33331
33332 #[test]
33333 fn sanitize_all_special_chars_gives_project() {
33334 assert_eq!(sanitize_project_label("!@#$%^"), "project");
33335 }
33336
33337 #[test]
33338 fn sanitize_empty_string_gives_project() {
33339 assert_eq!(sanitize_project_label(""), "project");
33340 }
33341
33342 #[test]
33343 fn sanitize_leading_trailing_hyphens_stripped() {
33344 assert_eq!(sanitize_project_label("!myrepo!"), "myrepo");
33345 }
33346
33347 #[test]
33348 fn sanitize_alphanumeric_preserved() {
33349 assert_eq!(sanitize_project_label("repo123"), "repo123");
33350 }
33351
33352 #[test]
33353 fn sanitize_dots_become_hyphens() {
33354 assert_eq!(sanitize_project_label("my.repo.name"), "my-repo-name");
33355 }
33356
33357 #[test]
33358 fn sanitize_mixed_slashes_uses_filename() {
33359 assert_eq!(sanitize_project_label("project-name"), "project-name");
33361 }
33362
33363 #[test]
33366 fn rate_limiter_allows_first_request() {
33367 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_hours(1));
33368 let ip: IpAddr = "127.0.0.1".parse().unwrap();
33369 assert!(rl.is_allowed(ip));
33370 }
33371
33372 #[test]
33373 fn rate_limiter_blocks_after_limit_reached() {
33374 let rl = IpRateLimiter::new(Duration::from_mins(1), 3, 5, Duration::from_hours(1));
33375 let ip: IpAddr = "10.0.0.1".parse().unwrap();
33376 assert!(rl.is_allowed(ip));
33377 assert!(rl.is_allowed(ip));
33378 assert!(rl.is_allowed(ip));
33379 assert!(!rl.is_allowed(ip), "4th request must be blocked");
33380 }
33381
33382 #[test]
33383 fn rate_limiter_allows_requests_up_to_limit() {
33384 let rl = IpRateLimiter::new(Duration::from_mins(1), 5, 5, Duration::from_hours(1));
33385 let ip: IpAddr = "10.0.0.2".parse().unwrap();
33386 for _ in 0..5 {
33387 assert!(rl.is_allowed(ip));
33388 }
33389 assert!(!rl.is_allowed(ip), "6th request must be blocked");
33390 }
33391
33392 #[test]
33393 fn rate_limiter_different_ips_are_independent() {
33394 let rl = IpRateLimiter::new(Duration::from_mins(1), 1, 5, Duration::from_hours(1));
33395 let ip1: IpAddr = "192.168.1.1".parse().unwrap();
33396 let ip2: IpAddr = "192.168.1.2".parse().unwrap();
33397 assert!(rl.is_allowed(ip1));
33398 assert!(!rl.is_allowed(ip1), "ip1 blocked after limit");
33399 assert!(rl.is_allowed(ip2), "ip2 must be independent");
33400 }
33401
33402 #[test]
33403 fn rate_limiter_auth_failure_not_locked_below_threshold() {
33404 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
33405 let ip: IpAddr = "10.0.0.3".parse().unwrap();
33406 rl.record_auth_failure(ip);
33407 rl.record_auth_failure(ip);
33408 assert!(
33409 !rl.is_auth_locked_out(ip),
33410 "not locked at 2 failures when threshold is 3"
33411 );
33412 }
33413
33414 #[test]
33415 fn rate_limiter_auth_failure_locked_at_threshold() {
33416 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
33417 let ip: IpAddr = "10.0.0.4".parse().unwrap();
33418 rl.record_auth_failure(ip);
33419 rl.record_auth_failure(ip);
33420 rl.record_auth_failure(ip);
33421 assert!(rl.is_auth_locked_out(ip), "must be locked after 3 failures");
33422 }
33423
33424 #[test]
33425 fn rate_limiter_auth_failure_different_ips_independent() {
33426 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 2, Duration::from_hours(1));
33427 let ip1: IpAddr = "10.0.1.1".parse().unwrap();
33428 let ip2: IpAddr = "10.0.1.2".parse().unwrap();
33429 rl.record_auth_failure(ip1);
33430 rl.record_auth_failure(ip1);
33431 assert!(rl.is_auth_locked_out(ip1));
33432 assert!(!rl.is_auth_locked_out(ip2), "ip2 must not be locked");
33433 }
33434
33435 #[test]
33436 fn rate_limiter_high_limit_never_blocks_normal_traffic() {
33437 let rl = IpRateLimiter::new(Duration::from_mins(1), 1000, 10, Duration::from_hours(1));
33438 let ip: IpAddr = "127.0.0.2".parse().unwrap();
33439 for _ in 0..100 {
33440 assert!(rl.is_allowed(ip));
33441 }
33442 }
33443
33444 #[test]
33447 fn strip_unc_plain_path_unchanged() {
33448 let p = PathBuf::from("C:\\Users\\user\\project");
33449 let result = strip_unc_prefix(p.clone());
33450 assert_eq!(result, p);
33451 }
33452
33453 #[test]
33454 fn strip_unc_with_drive_prefix_stripped() {
33455 let p = PathBuf::from(r"\\?\C:\Users\user\project");
33456 let result = strip_unc_prefix(p);
33457 assert_eq!(result, PathBuf::from(r"C:\Users\user\project"));
33458 }
33459
33460 #[test]
33461 fn strip_unc_with_network_prefix_stripped() {
33462 let p = PathBuf::from(r"\\?\UNC\server\share\dir");
33463 let result = strip_unc_prefix(p);
33464 assert_eq!(result, PathBuf::from(r"\\server\share\dir"));
33465 }
33466
33467 #[test]
33468 fn strip_unc_linux_path_unchanged() {
33469 let p = PathBuf::from("/home/user/project");
33470 let result = strip_unc_prefix(p.clone());
33471 assert_eq!(result, p);
33472 }
33473
33474 #[test]
33477 fn remote_to_commit_url_github_https() {
33478 let url = remote_to_commit_url("https://github.com/owner/repo.git", "abc1234");
33479 assert_eq!(
33480 url,
33481 Some("https://github.com/owner/repo/commit/abc1234".to_owned())
33482 );
33483 }
33484
33485 #[test]
33486 fn remote_to_commit_url_github_ssh() {
33487 let url = remote_to_commit_url("git@github.com:owner/repo.git", "abc1234");
33488 assert_eq!(
33489 url,
33490 Some("https://github.com/owner/repo/commit/abc1234".to_owned())
33491 );
33492 }
33493
33494 #[test]
33495 fn remote_to_commit_url_gitlab_uses_dash_commit() {
33496 let url = remote_to_commit_url("https://gitlab.com/group/repo.git", "deadbeef");
33497 assert_eq!(
33498 url,
33499 Some("https://gitlab.com/group/repo/-/commit/deadbeef".to_owned())
33500 );
33501 }
33502
33503 #[test]
33504 fn remote_to_commit_url_bitbucket_uses_commits() {
33505 let url = remote_to_commit_url("https://bitbucket.org/workspace/repo.git", "cafebabe");
33506 assert_eq!(
33507 url,
33508 Some("https://bitbucket.org/workspace/repo/commits/cafebabe".to_owned())
33509 );
33510 }
33511
33512 #[test]
33513 fn remote_to_commit_url_unknown_scheme_returns_none() {
33514 let url = remote_to_commit_url("ftp://example.com/repo.git", "abc");
33515 assert!(url.is_none());
33516 }
33517
33518 #[test]
33519 fn remote_to_commit_url_ssh_gitlab() {
33520 let url = remote_to_commit_url("git@gitlab.com:group/repo.git", "sha123");
33521 assert!(url.is_some());
33522 let u = url.unwrap();
33523 assert!(
33524 u.contains("/-/commit/sha123"),
33525 "gitlab ssh must use /-/commit/"
33526 );
33527 }
33528
33529 #[test]
33532 fn git_clone_dest_github_url_produces_safe_name() {
33533 let dir = PathBuf::from("/tmp/clones");
33534 let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
33535 let name = dest.file_name().unwrap().to_string_lossy();
33536 assert!(!name.is_empty());
33537 assert!(
33538 name.chars()
33539 .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.'),
33540 "clone dest must only contain safe chars, got: {name}"
33541 );
33542 }
33543
33544 #[test]
33545 fn git_clone_dest_is_inside_clones_dir() {
33546 let dir = PathBuf::from("/tmp/clones");
33547 let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
33548 assert!(
33549 dest.starts_with(&dir),
33550 "clone dest must be inside clones_dir"
33551 );
33552 }
33553
33554 #[test]
33555 fn git_clone_dest_truncates_to_80_chars_max() {
33556 let long_url = "https://github.com/".to_string() + &"a".repeat(200);
33557 let dir = PathBuf::from("/tmp/clones");
33558 let dest = git_clone_dest(&long_url, &dir);
33559 let name = dest.file_name().unwrap().to_string_lossy();
33560 assert!(
33561 name.len() <= 80,
33562 "clone dest name must be at most 80 chars, got {} chars: {name}",
33563 name.len()
33564 );
33565 }
33566
33567 #[test]
33568 fn git_clone_dest_special_chars_replaced_with_underscore() {
33569 let dir = PathBuf::from("/tmp/clones");
33570 let dest = git_clone_dest("git@github.com:owner/repo.git", &dir);
33571 let name = dest.file_name().unwrap().to_string_lossy();
33572 assert!(
33573 !name.contains('@') && !name.contains(':') && !name.contains('/'),
33574 "special chars must be replaced in clone dest, got: {name}"
33575 );
33576 }
33577
33578 #[test]
33579 fn git_clone_dest_different_urls_differ() {
33580 let dir = PathBuf::from("/tmp/clones");
33581 let a = git_clone_dest("https://github.com/owner/repo-a.git", &dir);
33582 let b = git_clone_dest("https://github.com/owner/repo-b.git", &dir);
33583 assert_ne!(
33584 a, b,
33585 "different repos must produce different clone dest names"
33586 );
33587 }
33588
33589 #[test]
33590 fn git_clone_dest_same_url_same_result() {
33591 let dir = PathBuf::from("/tmp/clones");
33592 let url = "https://github.com/owner/repo.git";
33593 assert_eq!(
33594 git_clone_dest(url, &dir),
33595 git_clone_dest(url, &dir),
33596 "same URL must always give same clone dest"
33597 );
33598 }
33599
33600 #[test]
33603 fn fmt_delta_positive_has_plus_prefix() {
33604 assert_eq!(fmt_delta(5), "+5");
33605 }
33606
33607 #[test]
33608 fn fmt_delta_negative_no_plus_prefix() {
33609 assert_eq!(fmt_delta(-3), "-3");
33610 }
33611
33612 #[test]
33613 fn fmt_delta_zero() {
33614 assert_eq!(fmt_delta(0), "0");
33615 }
33616
33617 #[test]
33620 fn delta_class_positive_is_pos() {
33621 assert_eq!(delta_class(1), "pos");
33622 }
33623
33624 #[test]
33625 fn delta_class_negative_is_neg() {
33626 assert_eq!(delta_class(-1), "neg");
33627 }
33628
33629 #[test]
33630 fn delta_class_zero_is_zero_class() {
33631 assert_eq!(delta_class(0), "zero");
33632 }
33633
33634 #[test]
33637 fn fmt_pct_zero_baseline_returns_em_dash() {
33638 assert_eq!(fmt_pct(100, 0), "\u{2014}");
33639 }
33640
33641 #[test]
33642 fn fmt_pct_positive_delta_has_plus_sign() {
33643 let result = fmt_pct(10, 100);
33644 assert!(result.starts_with('+'), "expected + prefix, got: {result}");
33645 }
33646
33647 #[test]
33648 fn fmt_pct_negative_delta_no_plus_sign() {
33649 let result = fmt_pct(-10, 100);
33650 assert!(!result.starts_with('+'), "unexpected + in: {result}");
33651 assert!(result.contains('%'));
33652 }
33653
33654 #[test]
33655 fn fmt_pct_near_zero_returns_pm_zero() {
33656 assert_eq!(fmt_pct(0, 1000), "\u{00b1}0%");
33657 }
33658
33659 #[test]
33662 fn summary_delta_no_prev_returns_dash_na() {
33663 let (display, class) = summary_delta(10, None);
33664 assert_eq!(display, "\u{2014}");
33665 assert_eq!(class, "na");
33666 }
33667
33668 #[test]
33669 fn summary_delta_increase_is_positive() {
33670 let (display, class) = summary_delta(15, Some(10));
33671 assert_eq!(display, "+5");
33672 assert_eq!(class, "pos");
33673 }
33674
33675 #[test]
33676 fn summary_delta_decrease_is_negative() {
33677 let (display, class) = summary_delta(5, Some(10));
33678 assert_eq!(display, "-5");
33679 assert_eq!(class, "neg");
33680 }
33681
33682 #[test]
33685 fn nth_weekday_first_monday_jan_2024_is_in_first_week() {
33686 use chrono::Datelike;
33687 let d = nth_weekday_of_month(2024, 1, chrono::Weekday::Mon, 1);
33688 assert_eq!(d.year(), 2024);
33689 assert_eq!(d.month(), 1);
33690 assert_eq!(d.weekday(), chrono::Weekday::Mon);
33691 assert!(d.day() <= 7);
33692 }
33693
33694 #[test]
33695 fn nth_weekday_second_sunday_march_2024_is_10th() {
33696 use chrono::Datelike;
33697 let d = nth_weekday_of_month(2024, 3, chrono::Weekday::Sun, 2);
33698 assert_eq!(d.weekday(), chrono::Weekday::Sun);
33699 assert_eq!(d.month(), 3);
33700 assert_eq!(d.day(), 10, "2nd Sunday in March 2024 is the 10th");
33701 }
33702
33703 #[test]
33706 fn is_pacific_dst_july_is_true() {
33707 let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
33708 assert!(is_pacific_dst(dt), "July must be PDT");
33709 }
33710
33711 #[test]
33712 fn is_pacific_dst_january_is_false() {
33713 let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
33714 assert!(!is_pacific_dst(dt), "January must be PST");
33715 }
33716
33717 #[test]
33718 fn fmt_la_time_summer_shows_pdt() {
33719 let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
33720 let result = fmt_la_time(dt);
33721 assert!(
33722 result.ends_with("PDT"),
33723 "summer must use PDT, got: {result}"
33724 );
33725 }
33726
33727 #[test]
33728 fn fmt_la_time_winter_shows_pst() {
33729 let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
33730 let result = fmt_la_time(dt);
33731 assert!(
33732 result.ends_with("PST"),
33733 "winter must use PST, got: {result}"
33734 );
33735 }
33736
33737 #[test]
33738 fn fmt_la_time_meta_summer_shows_pdt() {
33739 let dt: chrono::DateTime<chrono::Utc> = "2024-08-01T12:00:00Z".parse().unwrap();
33740 let result = fmt_la_time_meta(dt);
33741 assert!(
33742 result.ends_with("PDT"),
33743 "meta summer must use PDT, got: {result}"
33744 );
33745 }
33746
33747 #[test]
33748 fn fmt_la_time_meta_winter_shows_pst() {
33749 let dt: chrono::DateTime<chrono::Utc> = "2024-12-01T12:00:00Z".parse().unwrap();
33750 let result = fmt_la_time_meta(dt);
33751 assert!(
33752 result.ends_with("PST"),
33753 "meta winter must use PST, got: {result}"
33754 );
33755 }
33756
33757 #[test]
33760 fn fmt_git_date_valid_iso_returns_some() {
33761 assert!(fmt_git_date("2024-07-15T20:00:00Z").is_some());
33762 }
33763
33764 #[test]
33765 fn fmt_git_date_invalid_returns_none() {
33766 assert!(fmt_git_date("not-a-date").is_none());
33767 }
33768
33769 #[test]
33772 fn format_number_zero() {
33773 assert_eq!(format_number(0), "0");
33774 }
33775
33776 #[test]
33777 fn format_number_three_digits_no_comma() {
33778 assert_eq!(format_number(999), "999");
33779 }
33780
33781 #[test]
33782 fn format_number_four_digits_has_comma() {
33783 assert_eq!(format_number(1000), "1,000");
33784 }
33785
33786 #[test]
33787 fn format_number_seven_digits_two_commas() {
33788 assert_eq!(format_number(1_234_567), "1,234,567");
33789 }
33790
33791 #[test]
33792 fn format_number_one_million() {
33793 assert_eq!(format_number(1_000_000), "1,000,000");
33794 }
33795
33796 #[test]
33799 fn badge_text_px_empty_is_zero() {
33800 assert_eq!(badge_text_px(""), 0);
33801 }
33802
33803 #[test]
33804 fn badge_text_px_narrow_chars_smaller_than_normal() {
33805 assert!(
33806 badge_text_px("if") < badge_text_px("ab"),
33807 "'if' must be narrower than 'ab'"
33808 );
33809 }
33810
33811 #[test]
33812 fn badge_text_px_m_is_wider_than_a() {
33813 assert!(
33814 badge_text_px("m") > badge_text_px("a"),
33815 "'m' must be wider than 'a'"
33816 );
33817 }
33818
33819 #[test]
33820 fn render_badge_svg_contains_label_and_value() {
33821 let svg = render_badge_svg("coverage", "95%", "#4c1");
33822 assert!(svg.contains("coverage") && svg.contains("95%"));
33823 }
33824
33825 #[test]
33826 fn render_badge_svg_contains_color() {
33827 let svg = render_badge_svg("sloc", "12K", "#e05d44");
33828 assert!(svg.contains("#e05d44"), "SVG must contain fill color");
33829 }
33830
33831 #[test]
33832 fn render_badge_svg_escapes_ampersand_in_label() {
33833 let svg = render_badge_svg("test&label", "ok", "#4c1");
33834 assert!(svg.contains("&") && !svg.contains("test&label"));
33835 }
33836
33837 #[test]
33840 fn build_pdf_filename_slugifies_title() {
33841 let name = build_pdf_filename("My Project Report", "abc-def-1234");
33842 assert!(
33843 name.starts_with("my_project_report_")
33844 && std::path::Path::new(&name)
33845 .extension()
33846 .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
33847 );
33848 }
33849
33850 #[test]
33851 fn build_pdf_filename_uses_last_run_id_segment() {
33852 let name = build_pdf_filename("project", "uuid-part1-part2-ABCD");
33853 assert!(name.contains("ABCD"), "must use last segment of run_id");
33854 }
33855
33856 #[test]
33857 fn build_pdf_filename_empty_title_uses_report_prefix() {
33858 let name = build_pdf_filename("", "abc-def-9999");
33859 assert!(
33860 name.starts_with("report_")
33861 && std::path::Path::new(&name)
33862 .extension()
33863 .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
33864 );
33865 }
33866
33867 #[test]
33870 fn swap_chart_js_replaces_inline_block() {
33871 let html = "<html><head><script>// inline source</script></head><body></body></html>";
33872 let result = swap_inline_chart_js_for_static(html.to_string());
33873 assert!(result.contains(r#"src="/static/chart-report.js""#));
33874 assert!(!result.contains("inline source"));
33875 }
33876
33877 #[test]
33878 fn swap_chart_js_no_head_returns_unchanged() {
33879 let html = "<body>no head here</body>";
33880 assert_eq!(swap_inline_chart_js_for_static(html.to_string()), html);
33881 }
33882
33883 #[test]
33884 fn swap_chart_js_no_script_in_head_unchanged() {
33885 let html = "<html><head><style>.x{}</style></head><body></body></html>";
33886 let result = swap_inline_chart_js_for_static(html.to_string());
33887 assert!(!result.contains("chart-report.js"));
33888 }
33889
33890 #[test]
33893 fn patch_html_nonce_replaces_old_nonce() {
33894 let html = r#"<style nonce="old-nonce-123">body{}</style>"#;
33895 let result = patch_html_nonce(html, "new-nonce-456");
33896 assert!(result.contains(r#"nonce="new-nonce-456""#));
33897 assert!(!result.contains("old-nonce-123"));
33898 }
33899
33900 #[test]
33901 fn patch_html_nonce_injects_into_bare_style() {
33902 let html = "<style>body{color:red;}</style>";
33903 let result = patch_html_nonce(html, "fresh-nonce");
33904 assert!(result.contains(r#"<style nonce="fresh-nonce">"#));
33905 }
33906
33907 #[test]
33908 fn patch_html_nonce_injects_into_bare_script() {
33909 let html = "<script>console.log(1);</script>";
33910 let result = patch_html_nonce(html, "abc");
33911 assert!(result.contains(r#"<script nonce="abc">"#));
33912 }
33913
33914 #[test]
33917 fn is_html_report_file_result_html_matches() {
33918 let dir = tempfile::tempdir().unwrap();
33919 let path = dir.path().join("result_20240101.html");
33920 std::fs::write(&path, b"<html></html>").unwrap();
33921 assert!(is_html_report_file(&path));
33922 }
33923
33924 #[test]
33925 fn is_html_report_file_report_html_matches() {
33926 let dir = tempfile::tempdir().unwrap();
33927 let path = dir.path().join("report_abc.html");
33928 std::fs::write(&path, b"<html></html>").unwrap();
33929 assert!(is_html_report_file(&path));
33930 }
33931
33932 #[test]
33933 fn is_html_report_file_index_html_does_not_match() {
33934 let dir = tempfile::tempdir().unwrap();
33935 let path = dir.path().join("index.html");
33936 std::fs::write(&path, b"<html></html>").unwrap();
33937 assert!(!is_html_report_file(&path));
33938 }
33939
33940 #[test]
33941 fn is_html_report_file_nonexistent_returns_false() {
33942 assert!(!is_html_report_file(Path::new(
33943 "/nonexistent/result_xyz.html"
33944 )));
33945 }
33946
33947 #[test]
33948 fn find_html_report_in_dir_finds_result_html() {
33949 let dir = tempfile::tempdir().unwrap();
33950 std::fs::write(dir.path().join("result_xyz.html"), b"<html></html>").unwrap();
33951 assert!(find_html_report_in_dir(dir.path()).is_some());
33952 }
33953
33954 #[test]
33955 fn find_html_report_in_dir_empty_returns_none() {
33956 let dir = tempfile::tempdir().unwrap();
33957 assert!(find_html_report_in_dir(dir.path()).is_none());
33958 }
33959
33960 #[test]
33961 fn find_html_report_in_tree_finds_in_subdir() {
33962 let dir = tempfile::tempdir().unwrap();
33963 let subdir = dir.path().join("run-001");
33964 std::fs::create_dir_all(&subdir).unwrap();
33965 std::fs::write(subdir.join("result_abc.html"), b"<html></html>").unwrap();
33966 assert!(find_html_report_in_tree(dir.path()).is_some());
33967 }
33968
33969 #[test]
33972 fn derive_project_label_with_git_repo_and_ref() {
33973 let label = derive_project_label(
33974 Some("https://github.com/owner/my-repo.git"),
33975 Some("main"),
33976 "/fallback/path",
33977 );
33978 assert!(!label.is_empty(), "label must not be empty");
33979 assert!(
33980 label.contains("my") || label.contains("repo"),
33981 "got: {label}"
33982 );
33983 }
33984
33985 #[test]
33986 fn derive_project_label_fallback_to_path() {
33987 let label = derive_project_label(None, None, "/path/to/myproject");
33988 assert_eq!(label, "myproject");
33989 }
33990
33991 #[test]
33992 fn derive_project_label_empty_git_fields_use_path() {
33993 let label = derive_project_label(Some(""), Some(""), "/home/user/cool-app");
33994 assert_eq!(label, "cool-app");
33995 }
33996
33997 #[test]
34000 fn derive_file_stem_with_commit_appends_sha() {
34001 assert_eq!(
34002 derive_file_stem("myproject", Some("a1b2c3")),
34003 "myproject_a1b2c3"
34004 );
34005 }
34006
34007 #[test]
34008 fn derive_file_stem_without_commit_returns_label() {
34009 assert_eq!(derive_file_stem("myproject", None), "myproject");
34010 }
34011
34012 #[test]
34013 fn derive_file_stem_empty_commit_returns_label() {
34014 assert_eq!(derive_file_stem("myproject", Some("")), "myproject");
34015 }
34016
34017 #[test]
34020 fn split_patterns_none_is_empty() {
34021 assert!(split_patterns(None).is_empty());
34022 }
34023
34024 #[test]
34025 fn split_patterns_empty_string_is_empty() {
34026 assert!(split_patterns(Some("")).is_empty());
34027 }
34028
34029 #[test]
34030 fn split_patterns_comma_separated() {
34031 assert_eq!(
34032 split_patterns(Some("foo,bar,baz")),
34033 vec!["foo", "bar", "baz"]
34034 );
34035 }
34036
34037 #[test]
34038 fn split_patterns_newline_separated() {
34039 assert_eq!(
34040 split_patterns(Some("foo\nbar\nbaz")),
34041 vec!["foo", "bar", "baz"]
34042 );
34043 }
34044
34045 #[test]
34046 fn split_patterns_trims_whitespace() {
34047 assert_eq!(split_patterns(Some(" foo , bar ")), vec!["foo", "bar"]);
34048 }
34049
34050 #[test]
34053 fn make_git_label_empty_repo_empty_result() {
34054 assert_eq!(make_git_label("", "main"), "");
34055 }
34056
34057 #[test]
34058 fn make_git_label_empty_ref_empty_result() {
34059 assert_eq!(make_git_label("https://github.com/owner/repo", ""), "");
34060 }
34061
34062 #[test]
34063 fn make_git_label_basic_format() {
34064 assert_eq!(
34065 make_git_label("https://github.com/owner/my-repo.git", "main"),
34066 "my-repo_at_main_sloc"
34067 );
34068 }
34069
34070 #[test]
34071 fn make_git_label_slash_in_ref_replaced() {
34072 let label = make_git_label("https://example.com/repo.git", "feature/my-branch");
34073 assert!(
34074 !label.contains('/'),
34075 "slash in ref must be replaced: {label}"
34076 );
34077 }
34078
34079 #[test]
34082 fn format_dir_size_bytes() {
34083 assert_eq!(format_dir_size(500), "500 B");
34084 }
34085
34086 #[test]
34087 fn format_dir_size_kilobytes() {
34088 assert_eq!(format_dir_size(2048), "2 KB");
34089 }
34090
34091 #[test]
34092 fn format_dir_size_megabytes() {
34093 assert!(format_dir_size(5 * 1_048_576).contains("MB"));
34094 }
34095
34096 #[test]
34097 fn format_dir_size_gigabytes() {
34098 assert!(format_dir_size(2 * 1_073_741_824).contains("GB"));
34099 }
34100
34101 #[test]
34102 fn format_dir_size_zero() {
34103 assert_eq!(format_dir_size(0), "0 B");
34104 }
34105
34106 #[test]
34109 fn civil_from_days_epoch() {
34110 assert_eq!(civil_from_days(0), (1970, 1, 1));
34111 }
34112
34113 #[test]
34114 fn civil_from_days_one_year_later() {
34115 assert_eq!(civil_from_days(365), (1971, 1, 1));
34116 }
34117
34118 #[test]
34119 fn civil_from_days_31_days_is_feb_1_1970() {
34120 assert_eq!(civil_from_days(31), (1970, 2, 1));
34121 }
34122
34123 #[test]
34126 fn format_system_time_unix_epoch_formats_correctly() {
34127 assert_eq!(format_system_time(UNIX_EPOCH), "1970-01-01 00:00");
34128 }
34129
34130 #[test]
34131 fn format_system_time_31_days_after_epoch() {
34132 let t = UNIX_EPOCH + Duration::from_hours(744);
34133 assert_eq!(format_system_time(t), "1970-02-01 00:00");
34134 }
34135
34136 #[test]
34137 fn format_system_time_before_epoch_returns_dash() {
34138 if let Some(before) = UNIX_EPOCH.checked_sub(Duration::from_secs(1)) {
34139 assert_eq!(format_system_time(before), "-");
34140 }
34141 }
34142
34143 #[test]
34146 fn detect_language_name_dot_c() {
34147 assert_eq!(detect_language_name("main.c"), Some("C"));
34148 }
34149
34150 #[test]
34151 fn detect_language_name_dot_h() {
34152 assert_eq!(detect_language_name("defs.h"), Some("C"));
34153 }
34154
34155 #[test]
34156 fn detect_language_name_dot_cpp() {
34157 assert_eq!(detect_language_name("algo.cpp"), Some("C++"));
34158 }
34159
34160 #[test]
34161 fn detect_language_name_dot_py() {
34162 assert_eq!(detect_language_name("script.py"), Some("Python"));
34163 }
34164
34165 #[test]
34166 fn detect_language_name_dot_ps1() {
34167 assert_eq!(detect_language_name("Deploy.ps1"), Some("PowerShell"));
34168 }
34169
34170 #[test]
34171 fn detect_language_name_dot_cs() {
34172 assert_eq!(detect_language_name("Program.cs"), Some("C#"));
34173 }
34174
34175 #[test]
34176 fn detect_language_name_dot_sh() {
34177 assert_eq!(detect_language_name("run.sh"), Some("Shell"));
34178 }
34179
34180 #[test]
34181 fn detect_language_name_unknown_txt() {
34182 assert_eq!(detect_language_name("notes.txt"), None);
34183 }
34184
34185 #[test]
34188 fn language_icon_file_c() {
34189 assert_eq!(language_icon_file("C"), Some("c.png"));
34190 }
34191
34192 #[test]
34193 fn language_icon_file_python() {
34194 assert_eq!(language_icon_file("Python"), Some("python.png"));
34195 }
34196
34197 #[test]
34198 fn language_icon_file_dockerfile() {
34199 assert_eq!(language_icon_file("Dockerfile"), Some("docker.png"));
34200 }
34201
34202 #[test]
34203 fn language_icon_file_rust_is_none() {
34204 assert!(language_icon_file("Rust").is_none());
34205 }
34206
34207 #[test]
34208 fn language_icon_file_unknown_is_none() {
34209 assert!(language_icon_file("Fortran").is_none());
34210 }
34211
34212 #[test]
34215 fn language_inline_svg_rust_is_svg() {
34216 let svg = language_inline_svg("Rust").unwrap();
34217 assert!(svg.starts_with("<svg"));
34218 }
34219
34220 #[test]
34221 fn language_inline_svg_typescript_is_some() {
34222 assert!(language_inline_svg("TypeScript").is_some());
34223 }
34224
34225 #[test]
34226 fn language_inline_svg_unknown_is_none() {
34227 assert!(language_inline_svg("Fortran").is_none());
34228 }
34229
34230 #[test]
34233 fn classify_preview_file_c_supported() {
34234 assert!(matches!(
34235 classify_preview_file("main.c"),
34236 PreviewKind::Supported
34237 ));
34238 }
34239
34240 #[test]
34241 fn classify_preview_file_python_supported() {
34242 assert!(matches!(
34243 classify_preview_file("script.py"),
34244 PreviewKind::Supported
34245 ));
34246 }
34247
34248 #[test]
34249 fn classify_preview_file_png_skipped() {
34250 assert!(matches!(
34251 classify_preview_file("image.png"),
34252 PreviewKind::Skipped
34253 ));
34254 }
34255
34256 #[test]
34257 fn classify_preview_file_zip_skipped() {
34258 assert!(matches!(
34259 classify_preview_file("archive.zip"),
34260 PreviewKind::Skipped
34261 ));
34262 }
34263
34264 #[test]
34265 fn classify_preview_file_min_js_skipped() {
34266 assert!(matches!(
34267 classify_preview_file("bundle.min.js"),
34268 PreviewKind::Skipped
34269 ));
34270 }
34271
34272 #[test]
34273 fn classify_preview_file_rs_unsupported() {
34274 assert!(matches!(
34275 classify_preview_file("main.rs"),
34276 PreviewKind::Unsupported
34277 ));
34278 }
34279
34280 #[test]
34283 fn preview_relative_path_strips_root() {
34284 let root = PathBuf::from("/project");
34285 let path = PathBuf::from("/project/src/main.c");
34286 assert_eq!(preview_relative_path(&root, &path), "src/main.c");
34287 }
34288
34289 #[test]
34290 fn preview_relative_path_unrooted_includes_filename() {
34291 let root = PathBuf::from("/other");
34292 let path = PathBuf::from("/project/src/main.c");
34293 let result = preview_relative_path(&root, &path);
34294 assert!(result.contains("main.c"));
34295 }
34296
34297 #[test]
34298 fn preview_relative_path_uses_forward_slashes() {
34299 let root = PathBuf::from("/project");
34300 let path = PathBuf::from("/project/a/b/c.py");
34301 assert!(!preview_relative_path(&root, &path).contains('\\'));
34302 }
34303
34304 #[test]
34307 fn wildcard_match_exact_equal() {
34308 assert!(wildcard_match("foo", "foo"));
34309 }
34310
34311 #[test]
34312 fn wildcard_match_exact_mismatch() {
34313 assert!(!wildcard_match("foo", "bar"));
34314 }
34315
34316 #[test]
34317 fn wildcard_match_star_suffix() {
34318 assert!(wildcard_match("*.rs", "main.rs"));
34319 }
34320
34321 #[test]
34322 fn wildcard_match_star_middle_requires_suffix() {
34323 assert!(!wildcard_match("a*b", "ac"));
34324 }
34325
34326 #[test]
34327 fn wildcard_match_question_mark_single_char() {
34328 assert!(wildcard_match("f?o", "foo"));
34329 }
34330
34331 #[test]
34332 fn wildcard_match_double_star_nested() {
34333 assert!(wildcard_match("src/**", "src/a/b/c.rs"));
34334 }
34335
34336 #[test]
34337 fn wildcard_match_star_directory_entry() {
34338 assert!(wildcard_match("vendor/*", "vendor/crate"));
34339 }
34340
34341 #[test]
34342 fn wildcard_match_no_cross_prefix() {
34343 assert!(!wildcard_match("src/*.rs", "tests/foo.rs"));
34344 }
34345
34346 #[test]
34349 fn should_skip_empty_relative_is_false() {
34350 assert!(!should_skip_preview_directory("", &["vendor".to_string()]));
34351 }
34352
34353 #[test]
34354 fn should_skip_matching_pattern() {
34355 assert!(should_skip_preview_directory(
34356 "vendor",
34357 &["vendor".to_string()]
34358 ));
34359 }
34360
34361 #[test]
34362 fn should_skip_non_matching() {
34363 assert!(!should_skip_preview_directory(
34364 "src",
34365 &["vendor".to_string()]
34366 ));
34367 }
34368
34369 #[test]
34370 fn should_skip_wildcard_prefix() {
34371 assert!(should_skip_preview_directory(
34372 "target/debug",
34373 &["target*".to_string()]
34374 ));
34375 }
34376
34377 #[test]
34380 fn should_include_empty_relative_always_true() {
34381 assert!(should_include_preview_file("", &[], &[]));
34382 }
34383
34384 #[test]
34385 fn should_include_no_patterns_includes_all() {
34386 assert!(should_include_preview_file("src/main.c", &[], &[]));
34387 }
34388
34389 #[test]
34390 fn should_include_excluded_by_pattern() {
34391 assert!(!should_include_preview_file(
34392 "vendor/lib.c",
34393 &[],
34394 &["vendor/*".to_string()]
34395 ));
34396 }
34397
34398 #[test]
34399 fn should_include_include_pattern_filters() {
34400 assert!(!should_include_preview_file(
34401 "tests/test_foo.c",
34402 &["src/*".to_string()],
34403 &[]
34404 ));
34405 }
34406
34407 #[test]
34410 fn escape_html_ampersand() {
34411 assert_eq!(escape_html("a&b"), "a&b");
34412 }
34413
34414 #[test]
34415 fn escape_html_angle_brackets() {
34416 assert_eq!(escape_html("<br>"), "<br>");
34417 }
34418
34419 #[test]
34420 fn escape_html_double_quote() {
34421 assert_eq!(escape_html(r#"say "hello""#), "say "hello"");
34422 }
34423
34424 #[test]
34425 fn escape_html_single_quote() {
34426 assert_eq!(escape_html("it's"), "it's");
34427 }
34428
34429 #[test]
34430 fn escape_html_plain_text_unchanged() {
34431 assert_eq!(escape_html("hello world"), "hello world");
34432 }
34433
34434 fn make_mixed_scan_comparison() -> sloc_core::ScanComparison {
34437 sloc_core::ScanComparison {
34438 summary: sloc_core::SummaryDelta {
34439 baseline_run_id: "base".to_string(),
34440 current_run_id: "curr".to_string(),
34441 baseline_timestamp: chrono::Utc::now(),
34442 current_timestamp: chrono::Utc::now(),
34443 baseline_files: 4,
34444 current_files: 4,
34445 files_analyzed_delta: 0,
34446 baseline_code: 330,
34447 current_code: 400,
34448 code_lines_delta: 70,
34449 baseline_comments: 0,
34450 current_comments: 0,
34451 comment_lines_delta: 0,
34452 blank_lines_delta: 0,
34453 total_lines_delta: 70,
34454 coverage_lines_hit_delta: None,
34455 coverage_line_pct_delta: None,
34456 baseline_coverage_line_pct: None,
34457 current_coverage_line_pct: None,
34458 },
34459 file_deltas: vec![
34460 sloc_core::FileDelta {
34461 relative_path: "added.rs".to_string(),
34462 language: Some("Rust".to_string()),
34463 status: FileChangeStatus::Added,
34464 baseline_code: 0,
34465 current_code: 100,
34466 code_delta: 100,
34467 baseline_comment: 0,
34468 current_comment: 0,
34469 comment_delta: 0,
34470 baseline_blank: 0,
34471 current_blank: 0,
34472 blank_delta: 0,
34473 total_delta: 100,
34474 },
34475 sloc_core::FileDelta {
34476 relative_path: "removed.rs".to_string(),
34477 language: Some("Rust".to_string()),
34478 status: FileChangeStatus::Removed,
34479 baseline_code: 50,
34480 current_code: 0,
34481 code_delta: -50,
34482 baseline_comment: 0,
34483 current_comment: 0,
34484 comment_delta: 0,
34485 baseline_blank: 0,
34486 current_blank: 0,
34487 blank_delta: 0,
34488 total_delta: -50,
34489 },
34490 sloc_core::FileDelta {
34491 relative_path: "modified.rs".to_string(),
34492 language: Some("Rust".to_string()),
34493 status: FileChangeStatus::Modified,
34494 baseline_code: 80,
34495 current_code: 100,
34496 code_delta: 20,
34497 baseline_comment: 0,
34498 current_comment: 0,
34499 comment_delta: 0,
34500 baseline_blank: 0,
34501 current_blank: 0,
34502 blank_delta: 0,
34503 total_delta: 20,
34504 },
34505 sloc_core::FileDelta {
34506 relative_path: "unchanged.rs".to_string(),
34507 language: Some("Rust".to_string()),
34508 status: FileChangeStatus::Unchanged,
34509 baseline_code: 200,
34510 current_code: 200,
34511 code_delta: 0,
34512 baseline_comment: 0,
34513 current_comment: 0,
34514 comment_delta: 0,
34515 baseline_blank: 0,
34516 current_blank: 0,
34517 blank_delta: 0,
34518 total_delta: 0,
34519 },
34520 ],
34521 files_added: 1,
34522 files_removed: 1,
34523 files_modified: 1,
34524 files_unchanged: 1,
34525 }
34526 }
34527
34528 #[test]
34529 fn sum_added_counts_added_and_positive_modified() {
34530 let cmp = make_mixed_scan_comparison();
34531 assert_eq!(sum_added_code_lines(&cmp), 120);
34532 }
34533
34534 #[test]
34535 fn sum_removed_counts_removed_baseline() {
34536 let cmp = make_mixed_scan_comparison();
34537 assert_eq!(sum_removed_code_lines(&cmp), 50);
34538 }
34539
34540 #[test]
34541 fn sum_unmodified_counts_unchanged_files() {
34542 let cmp = make_mixed_scan_comparison();
34543 assert_eq!(sum_unmodified_code_lines(&cmp), 200);
34544 }
34545
34546 #[test]
34549 fn detect_coverage_tool_rust_project() {
34550 let dir = tempfile::tempdir().unwrap();
34551 std::fs::write(dir.path().join("Cargo.toml"), b"[package]").unwrap();
34552 let (tool, cmd) = detect_coverage_tool(dir.path());
34553 assert_eq!(tool, Some("cargo-llvm-cov"));
34554 assert!(cmd.is_some());
34555 }
34556
34557 #[test]
34558 fn detect_coverage_tool_java_gradle() {
34559 let dir = tempfile::tempdir().unwrap();
34560 std::fs::write(dir.path().join("build.gradle"), b"apply plugin: 'java'").unwrap();
34561 let (tool, _) = detect_coverage_tool(dir.path());
34562 assert_eq!(tool, Some("jacoco"));
34563 }
34564
34565 #[test]
34566 fn detect_coverage_tool_python_pyproject() {
34567 let dir = tempfile::tempdir().unwrap();
34568 std::fs::write(dir.path().join("pyproject.toml"), b"[tool.poetry]").unwrap();
34569 let (tool, _) = detect_coverage_tool(dir.path());
34570 assert_eq!(tool, Some("pytest-cov"));
34571 }
34572
34573 #[test]
34574 fn detect_coverage_tool_unknown_project() {
34575 let dir = tempfile::tempdir().unwrap();
34576 let (tool, cmd) = detect_coverage_tool(dir.path());
34577 assert!(tool.is_none() && cmd.is_none());
34578 }
34579
34580 #[test]
34583 fn sanitize_path_str_unc_drive_stripped() {
34584 assert_eq!(sanitize_path_str("//?/C:/Users/user"), "C:/Users/user");
34585 }
34586
34587 #[test]
34588 fn sanitize_path_str_unc_network_stripped() {
34589 assert_eq!(sanitize_path_str("//?/UNC/server/share"), "//server/share");
34590 }
34591
34592 #[test]
34593 fn sanitize_path_str_plain_path_unchanged() {
34594 assert_eq!(
34595 sanitize_path_str("/home/user/project"),
34596 "/home/user/project"
34597 );
34598 }
34599
34600 #[test]
34601 fn display_path_plain_linux_unchanged() {
34602 assert_eq!(
34603 display_path(Path::new("/home/user/project")),
34604 "/home/user/project"
34605 );
34606 }
34607
34608 #[test]
34609 fn display_path_unc_drive_stripped() {
34610 let result = display_path(Path::new(r"\\?\C:\Users\user"));
34611 assert_eq!(result, r"C:\Users\user");
34612 }
34613
34614 #[test]
34615 fn display_path_unc_network_stripped() {
34616 let result = display_path(Path::new(r"\\?\UNC\server\share"));
34617 assert_eq!(result, r"\\server\share");
34618 }
34619}
34620
34621#[cfg(test)]
34622mod coverage_boost_unit_tests {
34623 use super::*;
34624 use std::path::{Path, PathBuf};
34625
34626 #[tokio::test]
34630 async fn runtime_security_config_scenarios() {
34631 std::env::remove_var("SLOC_API_KEYS");
34632 std::env::remove_var("SLOC_API_KEY");
34633 std::env::remove_var("SLOC_TLS_CERT");
34634 std::env::remove_var("SLOC_TLS_KEY");
34635 std::env::remove_var("SLOC_TRUST_PROXY");
34636 std::env::remove_var("SLOC_TRUSTED_PROXY_IPS");
34637 let cfg = load_runtime_security_config(false);
34638 assert!(cfg.api_keys.is_empty());
34639 assert!(!cfg.tls_enabled);
34640 assert!(!cfg.trust_proxy);
34641
34642 std::env::set_var("SLOC_API_KEYS", "alpha, beta ,");
34643 std::env::set_var("SLOC_TRUST_PROXY", "1");
34644 std::env::set_var("SLOC_TRUSTED_PROXY_IPS", "127.0.0.1, 10.0.0.2");
34645 std::env::set_var("SLOC_RATE_LIMIT", "250");
34646 std::env::set_var("SLOC_AUTH_LOCKOUT_FAILS", "5");
34647 std::env::set_var("SLOC_AUTH_LOCKOUT_SECS", "60");
34648 let cfg = load_runtime_security_config(true);
34649 assert_eq!(cfg.api_keys.len(), 2, "two non-empty keys parsed");
34650 assert!(cfg.trust_proxy);
34651 assert_eq!(cfg.trusted_proxy_ips.len(), 2);
34652 std::env::remove_var("SLOC_API_KEYS");
34653 std::env::remove_var("SLOC_TRUST_PROXY");
34654 std::env::remove_var("SLOC_TRUSTED_PROXY_IPS");
34655 std::env::remove_var("SLOC_RATE_LIMIT");
34656 std::env::remove_var("SLOC_AUTH_LOCKOUT_FAILS");
34657 std::env::remove_var("SLOC_AUTH_LOCKOUT_SECS");
34658 }
34659
34660 #[test]
34661 fn cors_layer_builds_both_modes() {
34662 let _ = build_cors_layer(true);
34663 let _ = build_cors_layer(false);
34664 }
34665
34666 #[test]
34667 fn primary_lan_ip_callable() {
34668 let _ = primary_lan_ip();
34670 }
34671
34672 #[test]
34673 fn safe_redirect_allows_relative_rejects_absolute() {
34674 assert_eq!(safe_redirect("/view-reports"), "/view-reports");
34675 assert_eq!(safe_redirect("https://evil.example/x"), "/");
34676 assert_eq!(safe_redirect("javascript:alert(1)"), "/");
34677 assert_eq!(default_redirect(), "/view-reports");
34678 }
34679
34680 #[test]
34681 fn tarball_size_caps_env_override() {
34682 std::env::set_var("SLOC_MAX_TARBALL_MB", "1");
34683 std::env::set_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB", "2");
34684 let (c, d) = parse_tarball_size_caps();
34685 assert_eq!(c, 1024 * 1024);
34686 assert_eq!(d, 2 * 1024 * 1024);
34687 std::env::remove_var("SLOC_MAX_TARBALL_MB");
34688 std::env::remove_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB");
34689 let (c2, _) = parse_tarball_size_caps();
34690 assert_eq!(c2, 2048 * 1024 * 1024, "default 2048 MB");
34691 }
34692
34693 #[test]
34694 fn upload_path_helpers() {
34695 let base = upload_base_dir();
34696 let staged = upload_staging_path("abc123");
34697 assert!(staged.starts_with(&base));
34698 assert!(
34699 is_upload_tmp_path(&staged),
34700 "staging path is an upload tmp path"
34701 );
34702 assert!(!is_upload_tmp_path(Path::new("/etc/passwd")));
34703 }
34704
34705 #[test]
34706 fn git_clones_dir_env_override() {
34707 std::env::remove_var("SLOC_GIT_CLONES_DIR");
34708 let def = resolve_git_clones_dir(Path::new("/out"));
34709 assert_eq!(def, PathBuf::from("/out").join("git-clones"));
34710 std::env::set_var("SLOC_GIT_CLONES_DIR", "/custom/clones");
34711 assert_eq!(
34712 resolve_git_clones_dir(Path::new("/out")),
34713 PathBuf::from("/custom/clones")
34714 );
34715 std::env::remove_var("SLOC_GIT_CLONES_DIR");
34716 }
34717
34718 #[test]
34719 fn html_report_file_detection() {
34720 let dir = std::env::temp_dir().join("sloc_html_detect");
34721 let _ = std::fs::create_dir_all(&dir);
34722 let good = dir.join("report_x.html");
34723 std::fs::write(&good, "<html></html>").unwrap();
34724 let bad = dir.join("notes.txt");
34725 std::fs::write(&bad, "x").unwrap();
34726 assert!(is_html_report_file(&good));
34727 assert!(!is_html_report_file(&bad));
34728 assert!(find_html_report_in_dir(&dir).is_some());
34729 let _ = std::fs::remove_dir_all(&dir);
34730 }
34731
34732 #[test]
34733 fn multi_delta_class_and_format() {
34734 assert_eq!(multi_delta_class(5), "pos");
34735 assert_eq!(multi_delta_class(-5), "neg");
34736 assert_eq!(multi_delta_class(0), "zero");
34737 assert_eq!(multi_fmt_delta(3), "+3");
34738 assert_eq!(multi_fmt_delta(-3), "-3");
34739 assert_eq!(multi_fmt_delta(0), "0");
34740 }
34741
34742 #[test]
34743 fn git_clone_dest_sanitizes() {
34744 let dest = git_clone_dest("https://github.com/org/repo.git", Path::new("/clones"));
34745 assert!(dest.starts_with("/clones"));
34746 let name = dest.file_name().unwrap().to_str().unwrap();
34747 assert!(name
34748 .chars()
34749 .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.')));
34750 }
34751}
34752
34753#[cfg(test)]
34754mod tests_private {
34755 use super::*;
34756 use std::io::Read;
34757
34758 #[test]
34761 fn local_mode_never_refuses_start() {
34762 assert!(!refuse_unauthenticated_server(false, false));
34764 assert!(!refuse_unauthenticated_server(false, true));
34765 }
34766
34767 #[test]
34768 fn server_mode_with_key_is_allowed() {
34769 assert!(!refuse_unauthenticated_server(true, true));
34770 }
34771
34772 #[test]
34775 fn server_mode_auth_gate_respects_optin() {
34776 std::env::remove_var("SLOC_ALLOW_UNAUTHENTICATED");
34777 assert!(
34778 refuse_unauthenticated_server(true, false),
34779 "server mode + no key must fail closed by default"
34780 );
34781 std::env::set_var("SLOC_ALLOW_UNAUTHENTICATED", "1");
34782 assert!(
34783 !refuse_unauthenticated_server(true, false),
34784 "explicit opt-in must allow the unauthenticated server"
34785 );
34786 std::env::remove_var("SLOC_ALLOW_UNAUTHENTICATED");
34787 }
34788
34789 fn raw_tar_block(name: &str, data: &[u8]) -> Vec<u8> {
34795 let mut h = [0u8; 512];
34796 let nb = name.as_bytes();
34797 h[..nb.len()].copy_from_slice(nb);
34798 h[100..108].copy_from_slice(b"0000644\0");
34799 h[108..116].copy_from_slice(b"0000000\0");
34800 h[116..124].copy_from_slice(b"0000000\0");
34801 h[124..136].copy_from_slice(format!("{:011o}\0", data.len()).as_bytes());
34802 h[136..148].copy_from_slice(b"00000000000\0");
34803 h[156] = b'0'; h[257..263].copy_from_slice(b"ustar\0");
34805 h[263..265].copy_from_slice(b"00");
34806 for b in &mut h[148..156] {
34807 *b = b' ';
34808 }
34809 let sum: u32 = h.iter().map(|&b| u32::from(b)).sum();
34810 h[148..156].copy_from_slice(format!("{sum:06o}\0 ").as_bytes());
34811
34812 let mut out = h.to_vec();
34813 out.extend_from_slice(data);
34814 out.resize(out.len() + (512 - data.len() % 512) % 512, 0); out.resize(out.len() + 1024, 0); out
34817 }
34818
34819 #[tokio::test]
34823 async fn tarball_extraction_blocks_zip_slip() {
34824 use std::io::Write as _;
34825
34826 let base = std::env::temp_dir().join(format!("sloc_zipslip_{}", uuid::Uuid::new_v4()));
34827 let staging = base.join("staging");
34828 let tar_gz = base.join("evil.tar.gz");
34829 std::fs::create_dir_all(&base).unwrap();
34830
34831 {
34833 let f = std::fs::File::create(&tar_gz).unwrap();
34834 let mut enc = flate2::write::GzEncoder::new(f, flate2::Compression::default());
34835 enc.write_all(&raw_tar_block("../escaped.txt", b"pwned"))
34836 .unwrap();
34837 enc.finish().unwrap().flush().unwrap();
34838 }
34839
34840 let _ = extract_tarball_to_staging(&tar_gz, &staging, 10 * 1024 * 1024).await;
34842
34843 let escaped = base.join("escaped.txt");
34844 assert!(
34845 !escaped.exists(),
34846 "zip-slip entry escaped staging to {}",
34847 escaped.display()
34848 );
34849
34850 let _ = std::fs::remove_dir_all(&base);
34851 }
34852
34853 #[test]
34854 fn size_limit_reader_zero_remaining_returns_error() {
34855 let data = b"hello world";
34856 let mut reader = SizeLimitReader {
34857 inner: &data[..],
34858 remaining: 0,
34859 };
34860 let mut buf = [0u8; 4];
34861 assert!(reader.read(&mut buf).is_err());
34862 }
34863
34864 #[test]
34865 fn size_limit_reader_counts_bytes() {
34866 let data = b"hello world";
34867 let mut reader = SizeLimitReader {
34868 inner: &data[..],
34869 remaining: 5,
34870 };
34871 let mut buf = [0u8; 4];
34872 let n = reader.read(&mut buf).unwrap();
34873 assert_eq!(n, 4);
34874 assert_eq!(reader.remaining, 1);
34875 }
34876
34877 #[test]
34878 fn resolve_or_create_staging_with_valid_uuid_reuses_id() {
34879 let uuid = "12345678-1234-1234-1234-123456789012";
34880 let (id, path) = resolve_or_create_staging(Some(uuid));
34881 assert_eq!(id, uuid);
34882 assert!(path.to_string_lossy().contains("oxide-sloc-uploads"));
34883 }
34884
34885 #[test]
34886 fn resolve_or_create_staging_with_none_creates_new() {
34887 let (id1, _) = resolve_or_create_staging(None);
34888 let (id2, _) = resolve_or_create_staging(None);
34889 assert_ne!(id1, id2);
34890 }
34891
34892 #[test]
34893 fn resolve_or_create_staging_with_path_separator_creates_new() {
34894 let (id, _) = resolve_or_create_staging(Some("has/slash"));
34896 assert_ne!(id, "has/slash");
34897 }
34898
34899 #[test]
34900 fn auth_lockout_remaining_secs_no_entry_returns_zero() {
34901 use std::net::IpAddr;
34902 use std::str::FromStr;
34903 let limiter = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_mins(5));
34904 let ip = IpAddr::from_str("192.168.1.1").unwrap();
34905 assert_eq!(limiter.auth_lockout_remaining_secs(ip), 0);
34906 }
34907
34908 #[test]
34909 fn is_auth_locked_out_expired_entry_removed() {
34910 use std::net::IpAddr;
34911 use std::str::FromStr;
34912 let limiter = IpRateLimiter::new(
34913 Duration::from_mins(1),
34914 100,
34915 1, Duration::from_millis(1),
34917 );
34918 let ip = IpAddr::from_str("192.168.1.2").unwrap();
34919 limiter.record_auth_failure(ip);
34920 std::thread::sleep(Duration::from_millis(10));
34922 assert!(!limiter.is_auth_locked_out(ip));
34924 }
34925
34926 #[test]
34927 fn is_auth_locked_out_within_window_returns_true() {
34928 use std::net::IpAddr;
34929 use std::str::FromStr;
34930 let limiter = IpRateLimiter::new(
34931 Duration::from_mins(1),
34932 100,
34933 2, Duration::from_hours(1),
34935 );
34936 let ip = IpAddr::from_str("192.168.1.3").unwrap();
34937 limiter.record_auth_failure(ip);
34938 limiter.record_auth_failure(ip);
34939 assert!(limiter.is_auth_locked_out(ip));
34940 }
34941
34942 #[test]
34945 fn output_folder_hint_strips_json_subdir() {
34946 use std::path::Path;
34947 let path = Path::new("/output/scan1/json/result.json");
34948 let hint = output_folder_hint(path);
34949 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34950 }
34951
34952 #[test]
34953 fn output_folder_hint_strips_html_subdir() {
34954 use std::path::Path;
34955 let path = Path::new("/output/scan1/html/report.html");
34956 let hint = output_folder_hint(path);
34957 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34958 }
34959
34960 #[test]
34961 fn output_folder_hint_strips_pdf_subdir() {
34962 use std::path::Path;
34963 let path = Path::new("/output/scan1/pdf/report.pdf");
34964 let hint = output_folder_hint(path);
34965 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34966 }
34967
34968 #[test]
34969 fn output_folder_hint_strips_excel_subdir() {
34970 use std::path::Path;
34971 let path = Path::new("/output/scan1/excel/report.xlsx");
34972 let hint = output_folder_hint(path);
34973 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
34974 }
34975
34976 #[test]
34977 fn output_folder_hint_flat_layout_returns_direct_parent() {
34978 use std::path::Path;
34979 let path = Path::new("/output/scan1/result.json");
34980 let hint = output_folder_hint(path);
34981 assert!(
34982 hint.ends_with("scan1"),
34983 "expected direct parent, got: {hint}"
34984 );
34985 }
34986
34987 #[test]
34988 fn output_folder_hint_other_subdir_name_not_stripped() {
34989 use std::path::Path;
34990 let path = Path::new("/output/scan1/data/result.json");
34992 let hint = output_folder_hint(path);
34993 assert!(
34994 hint.ends_with("data"),
34995 "non-artifact subdir must not be stripped, got: {hint}"
34996 );
34997 }
34998
34999 #[test]
35002 fn find_file_by_ext_finds_matching_file() {
35003 let dir = std::env::temp_dir().join("sloc_web_fbe_test");
35004 let _ = fs::create_dir_all(&dir);
35005 let f = dir.join("report.pdf");
35006 let _ = fs::write(&f, b"dummy");
35007 let result = find_file_by_ext(&dir, "pdf");
35008 assert!(result.is_some(), "expected to find report.pdf");
35009 let _ = fs::remove_dir_all(&dir);
35010 }
35011
35012 #[test]
35013 fn find_file_by_ext_returns_none_for_missing_ext() {
35014 let dir = std::env::temp_dir().join("sloc_web_fbe_test2");
35015 let _ = fs::create_dir_all(&dir);
35016 let f = dir.join("report.json");
35017 let _ = fs::write(&f, b"{}");
35018 let result = find_file_by_ext(&dir, "pdf");
35019 assert!(result.is_none());
35020 let _ = fs::remove_dir_all(&dir);
35021 }
35022
35023 #[test]
35024 fn find_file_by_ext_returns_none_for_nonexistent_dir() {
35025 let dir = std::path::Path::new("/nonexistent/dir/that/does/not/exist");
35026 assert!(find_file_by_ext(dir, "json").is_none());
35027 }
35028
35029 #[test]
35032 fn collect_result_json_candidates_flat_root() {
35033 let root = std::env::temp_dir().join("sloc_web_crjc_flat");
35034 let _ = fs::create_dir_all(&root);
35035 let _ = fs::write(root.join("result.json"), b"{}");
35036 let candidates = collect_result_json_candidates(&root);
35037 assert!(!candidates.is_empty(), "should find result.json at root");
35038 let _ = fs::remove_dir_all(&root);
35039 }
35040
35041 #[test]
35042 fn collect_result_json_candidates_legacy_subdir() {
35043 let root = std::env::temp_dir().join("sloc_web_crjc_legacy");
35044 let sub = root.join("scanA");
35045 let _ = fs::create_dir_all(&sub);
35046 let _ = fs::write(sub.join("result.json"), b"{}");
35047 let candidates = collect_result_json_candidates(&root);
35048 assert!(
35049 !candidates.is_empty(),
35050 "should find result.json in legacy subdir"
35051 );
35052 let _ = fs::remove_dir_all(&root);
35053 }
35054
35055 #[test]
35056 fn collect_result_json_candidates_structured_json_subdir() {
35057 let root = std::env::temp_dir().join("sloc_web_crjc_struct");
35058 let json_sub = root.join("scanB").join("json");
35059 let _ = fs::create_dir_all(&json_sub);
35060 let _ = fs::write(json_sub.join("result.json"), b"{}");
35061 let candidates = collect_result_json_candidates(&root);
35062 assert!(
35063 !candidates.is_empty(),
35064 "should find result.json inside <subdir>/json/"
35065 );
35066 let _ = fs::remove_dir_all(&root);
35067 }
35068
35069 #[test]
35070 fn collect_result_json_candidates_empty_dir() {
35071 let root = std::env::temp_dir().join("sloc_web_crjc_empty");
35072 let _ = fs::create_dir_all(&root);
35073 let candidates = collect_result_json_candidates(&root);
35074 assert!(candidates.is_empty());
35075 let _ = fs::remove_dir_all(&root);
35076 }
35077}