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::{AuditVerifyReport, verify_audit_file};
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 Json, Router,
49 body::Body,
50 extract::{DefaultBodyLimit, Form, Path as AxumPath, Query, State},
51 http::{HeaderValue, Request, StatusCode, header},
52 middleware::{self, Next},
53 response::{Html, IntoResponse, Response},
54 routing::{get, post},
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 AnalysisRun, CleanupPolicy, CleanupPolicyStore, FileChangeStatus, MultiScanComparison,
74 RegistryEntry, ScanRegistry, ScanSummarySnapshot, SummaryTotals, WatchedDirsStore, analyze,
75 compute_delta, compute_multi_delta, read_json,
76};
77use sloc_report::{
78 ReportDeltaContext, render_html, render_html_with_delta, render_sub_report_html,
79 write_pdf_from_html, write_pdf_from_run,
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 unsafe 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 unsafe extern "system" {
153 fn GetCurrentThreadId() -> DWORD;
154 }
155
156 #[link(name = "shell32")]
157 unsafe 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 unsafe {
206 let mut existing = std::collections::HashSet::new();
207 let mut prev: HWND = core::ptr::null_mut();
208 loop {
209 let w = FindWindowExW(
210 core::ptr::null_mut(),
211 prev,
212 class_w.as_ptr(),
213 core::ptr::null(),
214 );
215 if w.is_null() {
216 break;
217 }
218 existing.insert(w as usize);
219 prev = w;
220 }
221 existing
222 }
223 }
224
225 unsafe fn find_new_explorer_hwnd(
226 class_w: &[u16],
227 existing: &std::collections::HashSet<usize>,
228 ) -> Option<HWND> {
229 unsafe {
230 let mut prev: HWND = core::ptr::null_mut();
231 loop {
232 let w = FindWindowExW(
233 core::ptr::null_mut(),
234 prev,
235 class_w.as_ptr(),
236 core::ptr::null(),
237 );
238 if w.is_null() {
239 return None;
240 }
241 if !existing.contains(&(w as usize)) {
242 return Some(w);
243 }
244 prev = w;
245 }
246 }
247 }
248
249 unsafe fn bring_to_front(hwnd: HWND) {
250 unsafe {
251 let my_tid = GetCurrentThreadId();
259 let fg_hwnd = GetForegroundWindow();
260 let fg_tid = if fg_hwnd.is_null() {
261 0
262 } else {
263 GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut())
264 };
265 let attached =
266 fg_tid != 0 && fg_tid != my_tid && AttachThreadInput(my_tid, fg_tid, 1) != 0;
267
268 ShowWindow(hwnd, 9);
271 BringWindowToTop(hwnd);
272 SetForegroundWindow(hwnd);
273 SwitchToThisWindow(hwnd, 1);
276
277 SetWindowPos(hwnd, (-1isize) as HWND, 0, 0, 0, 0, 0x0013);
282 SetWindowPos(hwnd, (-2isize) as HWND, 0, 0, 0, 0, 0x0013);
283
284 if attached {
285 AttachThreadInput(my_tid, fg_tid, 0);
286 }
287 }
288 }
289
290 pub fn open_folder_foreground(path: std::path::PathBuf) {
297 std::thread::spawn(move || {
298 use std::os::windows::ffi::OsStrExt;
299
300 let op: Vec<u16> = "explore\0".encode_utf16().collect();
301 let mut path_w: Vec<u16> = path.as_os_str().encode_wide().collect();
302 path_w.push(0);
303 let class_w: Vec<u16> = "CabinetWClass\0".encode_utf16().collect();
304
305 unsafe {
306 let existing = snapshot_explorer_hwnds(&class_w);
309 let fg_hwnd = GetForegroundWindow();
310 ShellExecuteW(
312 fg_hwnd,
313 op.as_ptr(),
314 path_w.as_ptr(),
315 core::ptr::null(),
316 core::ptr::null(),
317 1,
318 );
319
320 for _ in 0..40 {
324 std::thread::sleep(std::time::Duration::from_millis(75));
325 if let Some(w) = find_new_explorer_hwnd(&class_w, &existing) {
326 bring_to_front(w);
327 return;
328 }
329 }
330
331 let w = FindWindowW(class_w.as_ptr(), core::ptr::null());
334 if !w.is_null() {
335 bring_to_front(w);
336 }
337 }
338 });
339 }
340
341 #[cfg(feature = "native-dialog")]
345 pub fn flash_dialog_when_ready(title: String) {
346 std::thread::spawn(move || {
347 let title_w: Vec<u16> = title.encode_utf16().chain(core::iter::once(0)).collect();
348 for _ in 0..40 {
349 std::thread::sleep(std::time::Duration::from_millis(80));
350 unsafe {
351 let hwnd = FindWindowW(core::ptr::null(), title_w.as_ptr());
352 if !hwnd.is_null() {
353 SetForegroundWindow(hwnd);
354 BringWindowToTop(hwnd);
355 #[allow(non_snake_case)]
356 FlashWindowEx(&FLASHWINFO {
357 #[allow(clippy::cast_possible_truncation)]
360 cbSize: size_of::<FLASHWINFO>() as UINT,
361 hwnd,
362 dwFlags: FLASHW_ALL | FLASHW_TIMERNOFG,
363 uCount: 3,
364 dwTimeout: 0,
365 });
366 break;
367 }
368 }
369 }
370 });
371 }
372}
373
374pub(crate) struct IpRateLimiter {
377 window: Duration,
378 max_requests: usize,
379 pub(crate) auth_lockout_threshold: u32,
380 auth_lockout_window: Duration,
381 state: std::sync::Mutex<HashMap<IpAddr, VecDeque<Instant>>>,
382 auth_failures: std::sync::Mutex<HashMap<IpAddr, (u32, Instant)>>,
383}
384
385impl IpRateLimiter {
386 pub(crate) fn new(
387 window: Duration,
388 max_requests: usize,
389 auth_lockout_threshold: u32,
390 auth_lockout_window: Duration,
391 ) -> Self {
392 Self {
393 window,
394 max_requests,
395 auth_lockout_threshold,
396 auth_lockout_window,
397 state: std::sync::Mutex::new(HashMap::new()),
398 auth_failures: std::sync::Mutex::new(HashMap::new()),
399 }
400 }
401
402 #[allow(clippy::significant_drop_tightening)]
405 pub(crate) fn is_allowed(&self, ip: IpAddr) -> bool {
406 let now = Instant::now();
407 let cutoff = now.checked_sub(self.window).unwrap_or(now);
408 let mut state = self
409 .state
410 .lock()
411 .unwrap_or_else(std::sync::PoisonError::into_inner);
412 if state.len() > 10_000 {
413 state.retain(|_, bucket| {
414 while bucket.front().is_some_and(|t| *t <= cutoff) {
415 bucket.pop_front();
416 }
417 !bucket.is_empty()
418 });
419 }
420 let bucket = state.entry(ip).or_default();
421 while bucket.front().is_some_and(|t| *t <= cutoff) {
422 bucket.pop_front();
423 }
424 if bucket.len() >= self.max_requests {
425 false
426 } else {
427 bucket.push_back(now);
428 true
429 }
430 }
431
432 pub(crate) fn record_auth_failure(&self, ip: IpAddr) {
433 let now = Instant::now();
434 let mut map = self
435 .auth_failures
436 .lock()
437 .unwrap_or_else(std::sync::PoisonError::into_inner);
438 map.entry(ip)
439 .and_modify(|e| {
440 e.0 += 1;
441 e.1 = now;
442 })
443 .or_insert_with(|| (1, now));
444 }
445
446 pub(crate) fn is_auth_locked_out(&self, ip: IpAddr) -> bool {
447 let mut map = self
448 .auth_failures
449 .lock()
450 .unwrap_or_else(std::sync::PoisonError::into_inner);
451 let expired = map
452 .get(&ip)
453 .is_some_and(|e| e.1.elapsed() > self.auth_lockout_window);
454 if expired {
455 map.remove(&ip);
456 return false;
457 }
458 map.get(&ip)
459 .is_some_and(|e| e.0 >= self.auth_lockout_threshold)
460 }
461
462 pub(crate) fn auth_lockout_remaining_secs(&self, ip: IpAddr) -> u64 {
463 let map = self
464 .auth_failures
465 .lock()
466 .unwrap_or_else(std::sync::PoisonError::into_inner);
467 map.get(&ip).map_or(0, |e| {
468 self.auth_lockout_window
469 .checked_sub(e.1.elapsed())
470 .map_or(0, |r| r.as_secs())
471 })
472 }
473
474 pub(crate) fn spawn_pruning_task(limiter: Arc<Self>) {
475 tokio::spawn(async move {
476 let mut interval = tokio::time::interval(Duration::from_mins(1));
477 interval.tick().await; loop {
479 interval.tick().await;
480 let now = Instant::now();
481 let cutoff = now.checked_sub(limiter.window).unwrap_or(now);
482 {
483 let mut state = limiter
484 .state
485 .lock()
486 .unwrap_or_else(std::sync::PoisonError::into_inner);
487 state.retain(|_, bucket| {
488 while bucket.front().is_some_and(|t| *t <= cutoff) {
489 bucket.pop_front();
490 }
491 !bucket.is_empty()
492 });
493 }
494 {
495 let mut auth = limiter
496 .auth_failures
497 .lock()
498 .unwrap_or_else(std::sync::PoisonError::into_inner);
499 auth.retain(|_, e| e.1.elapsed() <= limiter.auth_lockout_window);
500 }
501 }
502 });
503 }
504}
505
506fn spawn_upload_staging_cleanup() {
510 tokio::spawn(async move {
511 let ttl_hours: u64 = std::env::var("SLOC_UPLOAD_TTL_HOURS")
512 .ok()
513 .and_then(|v| v.parse().ok())
514 .unwrap_or(4);
515 let ttl_secs = ttl_hours * 3600;
516 let mut interval = tokio::time::interval(Duration::from_hours(1));
517 interval.tick().await; loop {
519 interval.tick().await;
520 let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
521 let Ok(mut dir) = tokio::fs::read_dir(&upload_root).await else {
522 continue;
523 };
524 while let Ok(Some(entry)) = dir.next_entry().await {
525 let path = entry.path();
526 let age_secs = tokio::fs::metadata(&path)
527 .await
528 .ok()
529 .and_then(|m| m.modified().ok())
530 .and_then(|t| t.elapsed().ok())
531 .map_or(0, |d| d.as_secs());
532 if age_secs > ttl_secs {
533 tracing::debug!(
534 event = "upload_staging_cleanup",
535 path = %path.display(),
536 age_secs,
537 "removing stale upload staging directory"
538 );
539 let _ = tokio::fs::remove_dir_all(&path).await;
540 }
541 }
542 }
543 });
544}
545
546#[derive(Clone, Debug, Default)]
548struct RunResultContext {
549 prev_entry: Option<RegistryEntry>,
550 prev_scan_count: usize,
551 project_path: String,
552 cocomo_mode: String,
554 complexity_alert: u32,
556 #[allow(dead_code)]
558 exclude_duplicates: bool,
559}
560
561#[derive(Clone)]
563enum AsyncRunState {
564 Running {
565 started_at: std::time::Instant,
566 cancel_token: Arc<std::sync::atomic::AtomicBool>,
567 phase: Arc<std::sync::Mutex<String>>,
568 files_done: Arc<std::sync::atomic::AtomicUsize>,
569 files_total: Arc<std::sync::atomic::AtomicUsize>,
570 },
571 Complete {
573 run_id: String,
574 },
575 Failed {
576 message: String,
577 },
578 Cancelled,
579}
580
581#[derive(Debug, Clone, Serialize, Deserialize)]
584struct ScanProfile {
585 id: String,
586 name: String,
587 created_at: String,
588 params: serde_json::Value,
590}
591
592#[derive(Debug, Clone, Default, Serialize, Deserialize)]
593struct ScanProfileStore {
594 profiles: Vec<ScanProfile>,
595}
596
597impl ScanProfileStore {
598 fn load(path: &std::path::Path) -> Self {
599 fs::read_to_string(path)
600 .ok()
601 .and_then(|s| serde_json::from_str(&s).ok())
602 .unwrap_or_default()
603 }
604
605 fn save(&self, path: &std::path::Path) -> anyhow::Result<()> {
606 if let Some(parent) = path.parent() {
607 fs::create_dir_all(parent)?;
608 }
609 let json = serde_json::to_string_pretty(self)?;
610 fs::write(path, json)?;
611 Ok(())
612 }
613}
614
615#[derive(Clone, Copy)]
618pub(crate) struct SessionState {
619 pub(crate) absolute_expiry: Instant,
620 pub(crate) last_seen: Instant,
621}
622
623#[allow(clippy::struct_excessive_bools)]
629#[derive(Clone)]
630pub(crate) struct AppState {
631 pub(crate) base_config: AppConfig,
632 pub(crate) artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
633 pub(crate) async_runs: Arc<Mutex<HashMap<String, AsyncRunState>>>,
634 pub(crate) registry: Arc<Mutex<ScanRegistry>>,
635 pub(crate) registry_path: PathBuf,
636 pub(crate) analyze_semaphore: Arc<tokio::sync::Semaphore>,
637 pub(crate) server_mode: bool,
638 pub(crate) allow_unauthenticated: bool,
642 pub(crate) tls_enabled: bool,
643 pub(crate) api_keys: Arc<Vec<secrecy::SecretBox<String>>>,
644 pub(crate) readonly_api_keys: Arc<Vec<secrecy::SecretBox<String>>>,
648 pub(crate) rate_limiter: Arc<IpRateLimiter>,
649 pub(crate) trust_proxy: bool,
650 pub(crate) trusted_proxy_ips: Vec<IpAddr>,
653 pub(crate) git_clones_dir: PathBuf,
655 pub(crate) schedules: Arc<Mutex<ScheduleStore>>,
657 pub(crate) schedules_path: PathBuf,
658 pub(crate) scan_profiles: Arc<Mutex<ScanProfileStore>>,
660 pub(crate) scan_profiles_path: PathBuf,
661 pub(crate) sessions: Arc<std::sync::Mutex<HashMap<String, SessionState>>>,
662 pub(crate) confluence: Arc<Mutex<confluence::ConfluenceConfigStore>>,
664 pub(crate) confluence_path: PathBuf,
665 pub(crate) watched_dirs: Arc<Mutex<WatchedDirsStore>>,
667 pub(crate) watched_dirs_path: PathBuf,
668 pub(crate) cleanup_policy: Arc<Mutex<CleanupPolicyStore>>,
670 pub(crate) cleanup_policy_path: PathBuf,
671 pub(crate) cleanup_task_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
673}
674
675type PendingPdf = Option<(PathBuf, PathBuf, bool)>;
676
677#[derive(Clone, Debug)]
680pub(crate) struct RunArtifacts {
681 output_dir: PathBuf,
682 html_path: Option<PathBuf>,
683 pdf_path: Option<PathBuf>,
684 json_path: Option<PathBuf>,
685 csv_path: Option<PathBuf>,
686 xlsx_path: Option<PathBuf>,
687 scan_config_path: Option<PathBuf>,
688 report_title: String,
689 result_context: RunResultContext,
690}
691
692#[allow(clippy::too_many_lines)] fn build_router(state: AppState) -> Router {
694 let protected = Router::new()
695 .route("/", get(splash))
696 .route("/scan-setup", get(scan_setup_handler))
697 .route("/scan", get(index))
698 .route("/analyze", post(analyze_handler))
699 .route("/preview", get(preview_handler))
700 .route("/api/suggest-coverage", get(api_suggest_coverage))
701 .route("/pick-directory", get(pick_directory_handler))
702 .route("/open-path", get(open_path_handler))
703 .route("/pick-file", get(pick_file_handler))
704 .route(
705 "/api/upload-directory",
706 post(upload_directory_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
707 )
708 .route(
709 "/api/upload-file",
710 post(upload_file_handler).layer(DefaultBodyLimit::max(30 * 1024 * 1024)),
711 )
712 .route(
713 "/api/upload-tarball",
714 post(upload_tarball_handler)
717 .layer(DefaultBodyLimit::max(tarball_http_body_limit_bytes())),
718 )
719 .route("/locate-report", post(locate_report_handler))
720 .route("/locate-reports-dir", post(locate_reports_dir_handler))
721 .route("/relocate-scan", post(relocate_scan_handler))
722 .route("/watched-dirs/add", post(add_watched_dir_handler))
723 .route("/watched-dirs/remove", post(remove_watched_dir_handler))
724 .route("/watched-dirs/refresh", post(refresh_watched_dirs_handler))
725 .route("/view-reports", get(history_handler))
726 .route("/compare-scans", get(compare_select_handler))
727 .route("/compare", get(compare_handler))
728 .route("/multi-compare", get(multi_compare_handler))
729 .route("/images/{folder}/{file}", get(image_handler))
730 .route("/runs/{artifact}/{run_id}", get(artifact_handler))
731 .route("/api/metrics/latest", get(api_metrics_latest_handler))
732 .route("/api/metrics/{run_id}", get(api_metrics_run_handler))
733 .route("/api/metrics/history", get(api_metrics_history_handler))
734 .route("/api/metrics/churn", get(api_metrics_churn_handler))
735 .route(
736 "/api/metrics/submodules",
737 get(api_metrics_submodules_handler),
738 )
739 .route("/api/ingest", post(api_ingest_handler))
740 .route("/api/project-history", get(project_history_handler))
741 .route("/trend-reports", get(trend_report_handler))
742 .route("/test-metrics", get(test_metrics_handler))
743 .route("/api/runs/{wait_id}/status", get(async_run_status_handler))
744 .route("/api/runs/{wait_id}/cancel", post(cancel_run_handler))
745 .route("/api/runs/{run_id}/pdf-status", get(pdf_status_handler))
746 .route("/runs/result/{run_id}", get(async_run_result_handler))
747 .route("/embed/summary", get(embed_handler))
748 .route("/git-browser", get(git_browser::git_browser_handler))
750 .route("/api/git/refs", get(git_browser::api_list_refs))
751 .route("/api/git/scan-ref", get(git_browser::api_scan_ref))
752 .route("/api/git/compare-refs", get(git_browser::api_compare_refs))
753 .route(
758 "/export/pdf",
759 post(export_pdf_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
760 )
761 .route("/export-config", get(export_config_handler))
763 .route("/import-config", post(import_config_handler))
764 .route("/api/scan-profiles", get(api_list_scan_profiles))
766 .route("/api/scan-profiles", post(api_save_scan_profile))
767 .route(
768 "/api/scan-profiles/{id}",
769 axum::routing::delete(api_delete_scan_profile),
770 )
771 .route("/integrations", get(integrations::integrations_handler))
773 .route(
774 "/webhook-setup",
775 get(|| async { axum::response::Redirect::permanent("/integrations") }),
776 )
777 .route(
778 "/confluence-setup",
779 get(|| async { axum::response::Redirect::permanent("/integrations#confluence") }),
780 )
781 .route("/api/schedules", get(git_webhook::api_list_schedules))
782 .route("/api/schedules", post(git_webhook::api_create_schedule))
783 .route(
784 "/api/schedules",
785 axum::routing::delete(git_webhook::api_delete_schedule),
786 )
787 .route(
788 "/api/confluence/config",
789 get(confluence::api_get_confluence_config),
790 )
791 .route(
792 "/api/confluence/config",
793 post(confluence::api_save_confluence_config),
794 )
795 .route(
796 "/api/confluence/test",
797 post(confluence::api_test_confluence),
798 )
799 .route(
800 "/api/confluence/post",
801 post(confluence::api_post_to_confluence),
802 )
803 .route(
804 "/api/confluence/wiki-markup",
805 get(confluence::api_wiki_markup),
806 )
807 .route("/api/runs/{run_id}/bundle", get(download_bundle_handler))
809 .route(
810 "/api/runs/{run_id}",
811 axum::routing::delete(delete_run_handler),
812 )
813 .route("/api/runs/cleanup", post(cleanup_runs_handler))
814 .route(
816 "/api/cleanup-policy",
817 get(api_get_cleanup_policy)
818 .post(api_save_cleanup_policy)
819 .delete(api_delete_cleanup_policy),
820 )
821 .route("/api/cleanup-policy/run-now", post(api_run_cleanup_now))
822 .route("/api-docs", get(api_docs_handler))
824 .route("/metrics", get(metrics_handler))
826 .route_layer(middleware::from_fn_with_state(
827 state.clone(),
828 auth::require_api_key,
829 ));
830
831 protected
832 .route("/healthz", get(healthz))
833 .route("/api/health", get(healthz))
834 .route("/api/version", get(api_version_handler))
835 .route("/api/openapi.yaml", get(openapi_yaml_handler))
836 .route("/llms.txt", get(llms_txt_handler))
837 .route("/llms-full.txt", get(llms_full_txt_handler))
838 .route("/badge/{metric}", get(badge_handler))
839 .route("/static/chart.js", get(chart_js_handler))
840 .route("/static/chart-report.js", get(report_chart_js_handler))
841 .route("/auth/login", get(auth::auth_login_get))
842 .route("/auth/login", post(auth::auth_login_post))
843 .route("/auth/logout", post(auth::auth_logout))
844 .route("/auth/consent", get(auth::auth_consent_accept))
846 .route(
849 "/webhooks/github",
850 post(git_webhook::handle_github_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
851 )
852 .route(
853 "/webhooks/gitlab",
854 post(git_webhook::handle_gitlab_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
855 )
856 .route(
857 "/webhooks/bitbucket",
858 post(git_webhook::handle_bitbucket_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
859 )
860 .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
861 .layer(middleware::from_fn(consent_gate))
862 .layer(middleware::from_fn(csrf_protect))
863 .layer(middleware::from_fn_with_state(
864 state.clone(),
865 add_security_headers,
866 ))
867 .layer(build_cors_layer(state.server_mode))
868 .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
869 .with_state(state)
870}
871
872pub const TEST_SERVER_MODE_API_KEY: &str = "oxide-sloc-test-server-mode-internal-key";
875
876fn test_app_state(tmp_subdir: &str) -> AppState {
882 static TEST_DIR_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
889 unsafe { std::env::set_var("SLOC_HEADLESS", "1") };
891 let seq = TEST_DIR_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
892 let tmp = std::env::temp_dir().join(format!("{tmp_subdir}-{}-{seq}", std::process::id()));
893 AppState {
894 base_config: AppConfig::default(),
895 artifacts: Arc::new(Mutex::new(HashMap::new())),
896 async_runs: Arc::new(Mutex::new(HashMap::new())),
897 registry: Arc::new(Mutex::new(ScanRegistry::default())),
898 registry_path: tmp.join("registry.json"),
899 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
900 server_mode: false,
901 allow_unauthenticated: false,
902 tls_enabled: false,
903 api_keys: Arc::new(vec![]),
904 readonly_api_keys: Arc::new(vec![]),
905 rate_limiter: Arc::new(IpRateLimiter::new(
906 Duration::from_mins(1),
907 600,
908 10,
909 Duration::from_hours(1),
910 )),
911 trust_proxy: false,
912 trusted_proxy_ips: vec![],
913 git_clones_dir: tmp.join("git-clones"),
914 schedules: Arc::new(Mutex::new(ScheduleStore::default())),
915 schedules_path: tmp.join("schedules.json"),
916 scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
917 scan_profiles_path: tmp.join("scan_profiles.json"),
918 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
919 confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
920 confluence_path: tmp.join("confluence_config.json"),
921 watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
922 watched_dirs_path: tmp.join("watched_dirs.json"),
923 cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
924 cleanup_policy_path: tmp.join("cleanup_policy.json"),
925 cleanup_task_handle: Arc::new(Mutex::new(None)),
926 }
927}
928
929pub fn make_test_router() -> Router {
931 build_router(test_app_state("sloc_test"))
932}
933
934pub fn make_test_router_with_key(api_key: &str) -> Router {
936 let mut state = test_app_state("sloc_test_key");
937 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]);
938 build_router(state)
939}
940
941pub fn make_test_router_with_readonly_key(full_key: &str, readonly_key: &str) -> Router {
947 let mut state = test_app_state("sloc_test_readonly");
948 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(full_key.to_owned()))]);
949 state.readonly_api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
950 readonly_key.to_owned(),
951 ))]);
952 build_router(state)
953}
954
955pub fn make_test_router_server_mode() -> Router {
959 let mut state = test_app_state("sloc_test_server");
960 state.server_mode = true;
961 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
962 TEST_SERVER_MODE_API_KEY.to_owned(),
963 ))]);
964 build_router(state)
965}
966
967pub fn make_test_router_server_mode_with_roots(roots: Vec<PathBuf>) -> Router {
973 let mut state = test_app_state("sloc_test_server_roots");
974 state.server_mode = true;
975 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
976 TEST_SERVER_MODE_API_KEY.to_owned(),
977 ))]);
978 state.base_config.discovery.allowed_scan_roots = roots;
979 build_router(state)
980}
981
982pub fn make_test_router_exhausted_semaphore() -> Router {
985 let mut state = test_app_state("sloc_test_exhaust");
986 state.analyze_semaphore = Arc::new(tokio::sync::Semaphore::new(0));
987 build_router(state)
988}
989
990pub fn make_test_router_tight_rate_limit() -> Router {
993 let mut state = test_app_state("sloc_test_rate");
994 state.rate_limiter = Arc::new(IpRateLimiter::new(
995 Duration::from_mins(1),
996 2,
997 5,
998 Duration::from_secs(5),
999 ));
1000 build_router(state)
1001}
1002
1003pub fn make_test_router_tight_auth_lockout(api_key: &str) -> Router {
1006 let mut state = test_app_state("sloc_test_auth_lockout");
1007 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]);
1008 state.rate_limiter = Arc::new(IpRateLimiter::new(
1009 Duration::from_mins(1),
1010 600,
1011 2, Duration::from_millis(200), ));
1014 build_router(state)
1015}
1016
1017struct RuntimeSecurityConfig {
1018 api_keys: Vec<secrecy::SecretBox<String>>,
1019 readonly_api_keys: Vec<secrecy::SecretBox<String>>,
1020 tls_cert: Option<String>,
1021 tls_key: Option<String>,
1022 tls_enabled: bool,
1023 trust_proxy: bool,
1024 trusted_proxy_ips: Vec<IpAddr>,
1025 rate_limiter: Arc<IpRateLimiter>,
1026}
1027
1028fn allow_unauthenticated_server_mode() -> bool {
1031 matches!(
1032 std::env::var("SLOC_ALLOW_UNAUTHENTICATED").as_deref(),
1033 Ok("1" | "true" | "TRUE")
1034 )
1035}
1036
1037fn refuse_unauthenticated_server(server_mode: bool, has_api_keys: bool) -> bool {
1041 server_mode && !has_api_keys && !allow_unauthenticated_server_mode()
1042}
1043
1044fn hardened_mode() -> bool {
1049 std::env::var("SLOC_HARDENED").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1050}
1051
1052fn require_tls() -> bool {
1056 hardened_mode()
1057 || std::env::var("SLOC_REQUIRE_TLS")
1058 .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1059}
1060
1061pub(crate) fn session_idle_timeout() -> Option<Duration> {
1067 match std::env::var("SLOC_SESSION_IDLE_SECS")
1068 .ok()
1069 .and_then(|v| v.parse::<u64>().ok())
1070 {
1071 Some(0) => None,
1072 Some(secs) => Some(Duration::from_secs(secs)),
1073 None if hardened_mode() => Some(Duration::from_mins(15)),
1074 None => None,
1075 }
1076}
1077
1078const DEFAULT_CONSENT_NOTICE: &str = "This is a restricted system for authorized users only. \
1081Activity on this system may be monitored and recorded. By continuing you acknowledge that you \
1082are an authorized user and consent to such monitoring. Unauthorized use is prohibited.";
1083
1084fn consent_banner_text() -> Option<String> {
1088 if let Ok(t) = std::env::var("SLOC_CONSENT_BANNER") {
1089 let t = t.trim();
1090 if !t.is_empty() {
1091 return Some(t.to_owned());
1092 }
1093 }
1094 hardened_mode().then(|| DEFAULT_CONSENT_NOTICE.to_owned())
1095}
1096
1097fn consent_gate_applies(req: &Request<Body>) -> bool {
1101 const EXEMPT: &[&str] = &[
1102 "/auth/consent",
1103 "/static/",
1104 "/images/",
1105 "/assets/",
1106 "/badge/",
1107 "/healthz",
1108 "/api/",
1109 "/webhooks/",
1110 "/metrics",
1111 "/favicon",
1112 "/llms",
1113 ];
1114 if !matches!(
1115 *req.method(),
1116 axum::http::Method::GET | axum::http::Method::HEAD
1117 ) {
1118 return false;
1119 }
1120 let is_html = req
1121 .headers()
1122 .get(header::ACCEPT)
1123 .and_then(|v| v.to_str().ok())
1124 .is_some_and(|a| a.contains("text/html"));
1125 if !is_html {
1126 return false;
1127 }
1128 let path = req.uri().path();
1129 !EXEMPT.iter().any(|p| path.starts_with(p))
1130}
1131
1132fn request_has_consent(req: &Request<Body>) -> bool {
1134 req.headers()
1135 .get(header::COOKIE)
1136 .and_then(|v| v.to_str().ok())
1137 .is_some_and(|c| c.split(';').any(|p| p.trim() == "sloc_consent=1"))
1138}
1139
1140async fn consent_gate(req: Request<Body>, next: Next) -> Response {
1144 let Some(text) = consent_banner_text() else {
1145 return next.run(req).await;
1146 };
1147 if !consent_gate_applies(&req) || request_has_consent(&req) {
1148 return next.run(req).await;
1149 }
1150 let next_path = req.uri().path_and_query().map_or("/", |pq| pq.as_str());
1151 render_consent_page(&text, next_path)
1152}
1153
1154fn html_escape_consent(s: &str) -> String {
1156 s.replace('&', "&")
1157 .replace('<', "<")
1158 .replace('>', ">")
1159 .replace('"', """)
1160}
1161
1162fn render_consent_page(text: &str, next_path: &str) -> Response {
1165 let safe_next = if next_path.starts_with('/')
1167 && !next_path.starts_with("//")
1168 && !next_path.contains("://")
1169 && !next_path.starts_with("/auth/")
1170 {
1171 next_path
1172 } else {
1173 "/"
1174 };
1175 let accept_url = format!("/auth/consent?next={}", html_escape_consent(safe_next));
1176 let body = format!(
1177 r#"<!doctype html><html><head><meta charset="utf-8">
1178<meta name="viewport" content="width=device-width, initial-scale=1">
1179<title>Notice and Consent — OxideSLOC</title>
1180<style>body{{font-family:system-ui,sans-serif;max-width:560px;margin:64px auto;padding:0 24px;color:#2f241c}}
1181h1{{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}}
1182.agree{{display:inline-block;margin-top:20px;background:#b85d33;color:#fff;text-decoration:none;padding:10px 22px;border-radius:8px;font-weight:700}}
1183.agree:hover{{background:#a04d27}}</style>
1184</head><body>
1185<h1>Notice and Consent</h1>
1186<div class="notice">{}</div>
1187<a class="agree" href="{}">I Agree</a>
1188</body></html>"#,
1189 html_escape_consent(text),
1190 accept_url
1191 );
1192 (StatusCode::OK, Html(body)).into_response()
1193}
1194
1195#[allow(clippy::fn_params_excessive_bools)]
1201fn emit_server_mode_warnings(
1202 server_mode: bool,
1203 api_keys_empty: bool,
1204 tls_enabled: bool,
1205 trust_proxy: bool,
1206 trusted_proxy_ips: &[IpAddr],
1207) {
1208 if server_mode && api_keys_empty && allow_unauthenticated_server_mode() {
1209 println!(
1213 "WARNING: SLOC_ALLOW_UNAUTHENTICATED=1 — server mode is running with NO \
1214 authentication. Every web endpoint is publicly reachable. Do NOT use this \
1215 outside a trusted, isolated network."
1216 );
1217 }
1218 if server_mode && !tls_enabled {
1219 println!(
1220 "WARNING: TLS is not configured. Traffic is cleartext. \
1221 Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
1222 or terminate TLS at a reverse proxy (nginx, caddy)."
1223 );
1224 }
1225 if server_mode {
1226 println!(
1227 "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
1228 to restrict cross-origin access (comma-separated)."
1229 );
1230 }
1231 emit_trust_proxy_note(server_mode, trust_proxy, trusted_proxy_ips);
1232 if std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some() {
1233 println!(
1234 "WARNING: SLOC_GIT_SSL_NO_VERIFY is set — TLS certificate verification is \
1235 DISABLED for all git operations. Remove this variable before production use."
1236 );
1237 }
1238}
1239
1240fn emit_trust_proxy_note(server_mode: bool, trust_proxy: bool, trusted_proxy_ips: &[IpAddr]) {
1242 if trust_proxy {
1243 if trusted_proxy_ips.is_empty() {
1244 println!(
1245 "WARNING: SLOC_TRUST_PROXY=1 but SLOC_TRUSTED_PROXY_IPS is not set. \
1246 X-Forwarded-For will NOT be trusted until you specify the proxy IP(s) via \
1247 SLOC_TRUSTED_PROXY_IPS=192.168.1.1,10.0.0.1 to prevent rate-limit bypass."
1248 );
1249 } else {
1250 println!(
1251 "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For is trusted from proxy IPs: {}",
1252 trusted_proxy_ips
1253 .iter()
1254 .map(std::string::ToString::to_string)
1255 .collect::<Vec<_>>()
1256 .join(", ")
1257 );
1258 }
1259 } else if server_mode {
1260 println!(
1261 "NOTE: SLOC_TRUST_PROXY is not set. If oxide-sloc is behind a reverse proxy \
1262 (nginx, Caddy, Traefik), all LAN clients share one rate-limit bucket (the \
1263 proxy IP). Set SLOC_TRUST_PROXY=1 and SLOC_TRUSTED_PROXY_IPS=<proxy-ip> to \
1264 enable per-client rate limiting via X-Forwarded-For."
1265 );
1266 }
1267}
1268
1269fn load_runtime_security_config(server_mode: bool) -> RuntimeSecurityConfig {
1270 let api_keys: Vec<secrecy::SecretBox<String>> = std::env::var("SLOC_API_KEYS")
1271 .or_else(|_| std::env::var("SLOC_API_KEY"))
1272 .unwrap_or_default()
1273 .split(',')
1274 .map(str::trim)
1275 .filter(|s| !s.is_empty())
1276 .map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
1277 .collect();
1278 let readonly_api_keys: Vec<secrecy::SecretBox<String>> =
1279 std::env::var("SLOC_API_KEYS_READONLY")
1280 .unwrap_or_default()
1281 .split(',')
1282 .map(str::trim)
1283 .filter(|s| !s.is_empty())
1284 .map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
1285 .collect();
1286 let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
1287 let tls_key = std::env::var("SLOC_TLS_KEY").ok();
1288 let tls_enabled = tls_cert.is_some() && tls_key.is_some();
1289 let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
1290 let trusted_proxy_ips: Vec<IpAddr> = std::env::var("SLOC_TRUSTED_PROXY_IPS")
1291 .unwrap_or_default()
1292 .split(',')
1293 .filter_map(|s| s.trim().parse::<IpAddr>().ok())
1294 .collect();
1295 emit_server_mode_warnings(
1296 server_mode,
1297 api_keys.is_empty(),
1298 tls_enabled,
1299 trust_proxy,
1300 &trusted_proxy_ips,
1301 );
1302 let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
1303 .ok()
1304 .and_then(|v| v.parse::<u32>().ok())
1305 .unwrap_or_else(|| if hardened_mode() { 3 } else { 10 });
1306 let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
1307 .ok()
1308 .and_then(|v| v.parse::<u64>().ok())
1309 .unwrap_or(3600);
1310 let default_rpm: usize = if server_mode { 120 } else { 600 };
1314 let rate_limit_rpm = std::env::var("SLOC_RATE_LIMIT")
1315 .ok()
1316 .and_then(|v| v.parse::<usize>().ok())
1317 .unwrap_or(default_rpm);
1318 let rate_limiter = Arc::new(IpRateLimiter::new(
1319 Duration::from_mins(1),
1320 rate_limit_rpm,
1321 auth_lockout_threshold,
1322 Duration::from_secs(auth_lockout_secs),
1323 ));
1324 IpRateLimiter::spawn_pruning_task(Arc::clone(&rate_limiter));
1325 RuntimeSecurityConfig {
1326 api_keys,
1327 readonly_api_keys,
1328 tls_cert,
1329 tls_key,
1330 tls_enabled,
1331 trust_proxy,
1332 trusted_proxy_ips,
1333 rate_limiter,
1334 }
1335}
1336
1337#[allow(clippy::too_many_lines)]
1346pub async fn serve(config: AppConfig) -> Result<()> {
1347 let bind_address = config.web.bind_address.clone();
1348 let server_mode = config.web.server_mode;
1349 let output_root = resolve_output_root(None);
1350 let registry_path = std::env::var("SLOC_REGISTRY_PATH")
1352 .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
1353 let mut registry = ScanRegistry::load(®istry_path);
1354 registry.prune_stale();
1355 let _ = registry.save(®istry_path);
1356
1357 let sec = load_runtime_security_config(server_mode);
1358 if refuse_unauthenticated_server(server_mode, !sec.api_keys.is_empty()) {
1362 audit::record(
1363 "server_start_refused",
1364 "denied",
1365 &[(
1366 "reason",
1367 "server mode requires SLOC_API_KEY / SLOC_API_KEYS",
1368 )],
1369 );
1370 anyhow::bail!(
1371 "refusing to start: server mode requires authentication. Set SLOC_API_KEY \
1372 (or SLOC_API_KEYS=<k1,k2>) to a secret before launching. To run an \
1373 unauthenticated server on a trusted, isolated network, explicitly set \
1374 SLOC_ALLOW_UNAUTHENTICATED=1 (not recommended)."
1375 );
1376 }
1377 if server_mode && sec.api_keys.is_empty() {
1378 audit::record("server_start_unauthenticated", "warning", &[]);
1379 }
1380 spawn_upload_staging_cleanup();
1381
1382 let git_clones_dir = resolve_git_clones_dir(&output_root);
1383 let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
1384 .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
1385 let schedules = ScheduleStore::load(&schedules_path);
1386 let scan_profiles_path = std::env::var("SLOC_SCAN_PROFILES_PATH")
1387 .map_or_else(|_| output_root.join("scan_profiles.json"), PathBuf::from);
1388 let scan_profiles = ScanProfileStore::load(&scan_profiles_path);
1389 let confluence_path = std::env::var("SLOC_CONFLUENCE_CONFIG_PATH").map_or_else(
1390 |_| output_root.join("confluence_config.json"),
1391 PathBuf::from,
1392 );
1393 let confluence = confluence::ConfluenceConfigStore::load(&confluence_path);
1394 let watched_dirs_path = std::env::var("SLOC_WATCHED_DIRS_PATH")
1395 .map_or_else(|_| output_root.join("watched_dirs.json"), PathBuf::from);
1396 let watched_dirs = WatchedDirsStore::load(&watched_dirs_path);
1397 let cleanup_policy_path = std::env::var("SLOC_CLEANUP_POLICY_PATH")
1398 .map_or_else(|_| output_root.join("cleanup_policy.json"), PathBuf::from);
1399 let cleanup_policy = CleanupPolicyStore::load(&cleanup_policy_path);
1400
1401 let state = AppState {
1402 base_config: config,
1403 artifacts: Arc::new(Mutex::new(HashMap::new())),
1404 async_runs: Arc::new(Mutex::new(HashMap::new())),
1405 registry: Arc::new(Mutex::new(registry)),
1406 registry_path,
1407 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
1408 server_mode,
1409 allow_unauthenticated: allow_unauthenticated_server_mode(),
1410 tls_enabled: sec.tls_enabled,
1411 api_keys: Arc::new(sec.api_keys),
1412 readonly_api_keys: Arc::new(sec.readonly_api_keys),
1413 rate_limiter: sec.rate_limiter,
1414 trust_proxy: sec.trust_proxy,
1415 trusted_proxy_ips: sec.trusted_proxy_ips,
1416 git_clones_dir,
1417 schedules: Arc::new(Mutex::new(schedules)),
1418 schedules_path,
1419 scan_profiles: Arc::new(Mutex::new(scan_profiles)),
1420 scan_profiles_path,
1421 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
1422 confluence: Arc::new(Mutex::new(confluence)),
1423 confluence_path,
1424 watched_dirs: Arc::new(Mutex::new(watched_dirs)),
1425 watched_dirs_path,
1426 cleanup_policy: Arc::new(Mutex::new(cleanup_policy)),
1427 cleanup_policy_path,
1428 cleanup_task_handle: Arc::new(Mutex::new(None)),
1429 };
1430
1431 restart_poll_schedules(&state).await;
1432 warn_insecure_gitlab_webhooks(&state).await;
1433
1434 {
1436 let enabled = state
1437 .cleanup_policy
1438 .lock()
1439 .await
1440 .policy
1441 .as_ref()
1442 .is_some_and(|p| p.enabled);
1443 if enabled {
1444 let handle = spawn_cleanup_policy_task(state.clone());
1445 *state.cleanup_task_handle.lock().await = Some(handle);
1446 }
1447 }
1448
1449 let app = build_router(state.clone());
1450
1451 let preferred: SocketAddr = bind_address
1456 .parse()
1457 .with_context(|| format!("invalid bind address: {bind_address}"))?;
1458
1459 if require_tls() && !preferred.ip().is_loopback() && !sec.tls_enabled {
1464 audit::record(
1465 "server_start_refused",
1466 "denied",
1467 &[("reason", "TLS required for non-loopback bind")],
1468 );
1469 anyhow::bail!(
1470 "refusing to start: TLS is required for a network-facing bind ({preferred}) but \
1471 SLOC_TLS_CERT / SLOC_TLS_KEY are not set. Provide a certificate and key, bind to \
1472 a loopback address, or unset SLOC_REQUIRE_TLS / SLOC_HARDENED."
1473 );
1474 }
1475
1476 let (listener, addr) = {
1477 let candidates = (0u16..=9).map(|offset| {
1478 let mut a = preferred;
1479 a.set_port(preferred.port().saturating_add(offset));
1480 a
1481 });
1482 let mut found = None;
1483 for candidate in candidates {
1484 if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
1485 found = Some((l, candidate));
1486 break;
1487 }
1488 }
1489 found.ok_or_else(|| {
1490 anyhow::anyhow!(
1491 "failed to bind local web UI on {} (tried ports {}-{}): all in use",
1492 bind_address,
1493 preferred.port(),
1494 preferred.port().saturating_add(9)
1495 )
1496 })?
1497 };
1498 if addr != preferred {
1499 eprintln!(
1500 "NOTE: port {} is blocked by a system socket (Windows zombie); \
1501 using {} instead.",
1502 preferred.port(),
1503 addr.port()
1504 );
1505 }
1506
1507 if sec.tls_enabled {
1508 let cert_path = sec
1509 .tls_cert
1510 .expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
1511 let key_path = sec
1512 .tls_key
1513 .expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
1514 let tls_config = build_tls_config(&cert_path, &key_path)
1515 .context("failed to load TLS certificate/key")?;
1516 let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
1517
1518 let url = format!("https://{addr}/");
1519 println!("OxideSLOC server running at {url} (TLS)");
1520 if let Some(lan) = wildcard_lan_url(&url) {
1521 println!(" Reachable on the LAN at {lan} (sign in at {lan}auth/login)");
1522 }
1523 println!("Use Ctrl+C to stop.");
1524
1525 return serve_tls(listener, app, acceptor, server_mode).await;
1526 }
1527
1528 let url = format!("http://{addr}/");
1529 log_startup_url(&url, server_mode);
1530
1531 axum::serve(
1532 listener,
1533 app.into_make_service_with_connect_info::<SocketAddr>(),
1534 )
1535 .with_graceful_shutdown(shutdown_signal(server_mode))
1536 .await
1537 .context("web server terminated unexpectedly")
1538}
1539
1540fn primary_lan_ip() -> Option<String> {
1544 let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
1545 socket.connect("8.8.8.8:80").ok()?;
1546 let addr = socket.local_addr().ok()?;
1547 let ip = addr.ip();
1548 if ip.is_loopback() {
1549 return None;
1550 }
1551 Some(ip.to_string())
1552}
1553
1554fn wildcard_lan_url(url: &str) -> Option<String> {
1559 if url.contains("0.0.0.0") {
1560 primary_lan_ip().map(|ip| url.replacen("0.0.0.0", &ip, 1))
1561 } else if url.contains("[::]") {
1562 primary_lan_ip().map(|ip| url.replacen("[::]", &ip, 1))
1563 } else {
1564 None
1565 }
1566}
1567
1568fn log_startup_url(url: &str, server_mode: bool) {
1570 if server_mode {
1571 println!("OxideSLOC server running at {url}");
1572 if let Some(lan) = wildcard_lan_url(url) {
1573 println!(" Reachable on the LAN at {lan} (sign in at {lan}auth/login)");
1574 }
1575 println!("Use Ctrl+C to stop.");
1576 } else {
1577 println!("OxideSLOC local web UI running at {url}");
1578 println!("Press Ctrl+C to stop the server.");
1579 let open_url = url.to_owned();
1580 tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
1581 }
1582}
1583
1584fn open_browser_tab(url: &str) {
1586 #[cfg(target_os = "windows")]
1591 let _ = std::process::Command::new("rundll32")
1592 .args(["url.dll,FileProtocolHandler", url])
1593 .stdout(Stdio::null())
1594 .stderr(Stdio::null())
1595 .spawn();
1596 #[cfg(target_os = "macos")]
1597 let _ = std::process::Command::new("open")
1598 .arg(url)
1599 .stdout(Stdio::null())
1600 .stderr(Stdio::null())
1601 .spawn();
1602 #[cfg(target_os = "linux")]
1603 let _ = std::process::Command::new("xdg-open")
1604 .arg(url)
1605 .stdout(Stdio::null())
1606 .stderr(Stdio::null())
1607 .spawn();
1608}
1609
1610async fn shutdown_signal(server_mode: bool) {
1612 if tokio::signal::ctrl_c().await.is_ok() {
1613 println!();
1614 if server_mode {
1615 println!("Shutting down OxideSLOC server...");
1616 } else {
1617 println!("Shutting down OxideSLOC local web UI...");
1618 }
1619 println!("Server stopped cleanly.");
1620 }
1621}
1622
1623fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
1625 use rustls_pki_types::pem::PemObject;
1626 use rustls_pki_types::{CertificateDer, PrivateKeyDer};
1627
1628 let cert_bytes =
1629 fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
1630 let key_bytes =
1631 fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
1632
1633 let cert_chain: Vec<CertificateDer<'static>> =
1634 CertificateDer::pem_slice_iter(cert_bytes.as_slice())
1635 .collect::<std::result::Result<_, _>>()
1636 .context("failed to parse TLS certificates")?;
1637
1638 let key = PrivateKeyDer::from_pem_slice(key_bytes.as_slice())
1639 .context("failed to parse TLS private key")?;
1640
1641 let builder = rustls::ServerConfig::builder_with_protocol_versions(&[
1646 &rustls::version::TLS13,
1647 &rustls::version::TLS12,
1648 ]);
1649
1650 let config = match client_cert_verifier()? {
1655 Some(verifier) => builder
1656 .with_client_cert_verifier(verifier)
1657 .with_single_cert(cert_chain, key),
1658 None => builder
1659 .with_no_client_auth()
1660 .with_single_cert(cert_chain, key),
1661 };
1662 config.context("failed to build TLS server config")
1663}
1664
1665fn client_cert_verifier() -> Result<Option<Arc<dyn rustls::server::danger::ClientCertVerifier>>> {
1668 use rustls_pki_types::CertificateDer;
1669 use rustls_pki_types::pem::PemObject;
1670
1671 let Some(ca_path) = std::env::var("SLOC_TLS_CLIENT_CA")
1672 .ok()
1673 .filter(|s| !s.is_empty())
1674 else {
1675 return Ok(None);
1676 };
1677 let ca_bytes = fs::read(&ca_path)
1678 .with_context(|| format!("failed to read client CA bundle: {ca_path}"))?;
1679 let mut roots = rustls::RootCertStore::empty();
1680 for cert in CertificateDer::pem_slice_iter(ca_bytes.as_slice()) {
1681 let cert = cert.context("failed to parse client CA certificate")?;
1682 roots
1683 .add(cert)
1684 .context("failed to add client CA certificate to root store")?;
1685 }
1686 let verifier = rustls::server::WebPkiClientVerifier::builder(Arc::new(roots))
1687 .build()
1688 .context("failed to build client certificate verifier")?;
1689 Ok(Some(verifier))
1690}
1691
1692async fn serve_tls(
1694 listener: tokio::net::TcpListener,
1695 app: Router,
1696 acceptor: tokio_rustls::TlsAcceptor,
1697 server_mode: bool,
1698) -> Result<()> {
1699 use hyper_util::rt::{TokioExecutor, TokioIo};
1700 use hyper_util::server::conn::auto::Builder as ConnBuilder;
1701 use hyper_util::service::TowerToHyperService;
1702 use tower::{Service, ServiceExt};
1703
1704 let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
1705
1706 loop {
1707 tokio::select! {
1708 biased;
1709 _ = tokio::signal::ctrl_c() => {
1710 println!();
1711 if server_mode {
1712 println!("Shutting down OxideSLOC server...");
1713 } else {
1714 println!("Shutting down OxideSLOC local web UI...");
1715 }
1716 println!("Server stopped cleanly.");
1717 return Ok(());
1718 }
1719 result = listener.accept() => {
1720 let (tcp, peer_addr) = result.context("TLS accept failed")?;
1721 let acceptor = acceptor.clone();
1722 let mut factory = make_svc.clone();
1723
1724 tokio::spawn(async move {
1725 let tls = match acceptor.accept(tcp).await {
1726 Ok(s) => s,
1727 Err(e) => {
1728 eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
1729 return;
1730 }
1731 };
1732 let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
1733 Ok(f) => match Service::call(f, peer_addr).await {
1734 Ok(s) => s,
1735 Err(_) => return,
1736 },
1737 Err(_) => return,
1738 };
1739 let io = TokioIo::new(tls);
1740 if let Err(e) = ConnBuilder::new(TokioExecutor::new())
1741 .serve_connection(io, TowerToHyperService::new(svc))
1742 .await
1743 {
1744 eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
1745 }
1746 });
1747 }
1748 }
1749 }
1750}
1751
1752fn build_cors_layer(server_mode: bool) -> CorsLayer {
1755 if server_mode {
1756 let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
1757 .unwrap_or_default()
1758 .split(',')
1759 .filter(|s| !s.is_empty())
1760 .filter_map(|s| s.trim().parse().ok())
1761 .collect();
1762 if allowed.is_empty() {
1763 return CorsLayer::new();
1764 }
1765 CorsLayer::new()
1766 .allow_origin(AllowOrigin::list(allowed))
1767 .allow_methods(AllowMethods::list([
1768 axum::http::Method::GET,
1769 axum::http::Method::POST,
1770 ]))
1771 .allow_headers(AllowHeaders::list([
1772 axum::http::header::AUTHORIZATION,
1773 axum::http::header::CONTENT_TYPE,
1774 ]))
1775 } else {
1776 CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
1777 let s = origin.to_str().unwrap_or("");
1778 s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
1779 }))
1780 }
1781}
1782
1783async fn add_security_headers(
1784 State(state): State<AppState>,
1785 mut req: Request<Body>,
1786 next: Next,
1787) -> Response {
1788 let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
1789 req.extensions_mut().insert(CspNonce(nonce.clone()));
1790 let mut resp = next.run(req).await;
1791 inject_page_fade_into_html(&mut resp, &nonce).await;
1792 let h = resp.headers_mut();
1793 let frame_ancestors = std::env::var("SLOC_FRAME_ANCESTORS")
1801 .ok()
1802 .map(|v| v.trim().to_string())
1803 .filter(|v| !v.is_empty());
1804 if frame_ancestors.is_none() {
1805 h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
1806 }
1807 let frame_ancestors_directive = frame_ancestors.as_deref().unwrap_or("'none'");
1808 h.insert(
1809 "X-Content-Type-Options",
1810 HeaderValue::from_static("nosniff"),
1811 );
1812 h.insert(
1813 "Referrer-Policy",
1814 HeaderValue::from_static("strict-origin-when-cross-origin"),
1815 );
1816 let csp = format!(
1817 "default-src 'self'; \
1818 base-uri 'self'; \
1819 form-action 'self'; \
1820 style-src 'self' 'unsafe-inline'; \
1821 img-src 'self' data: blob:; \
1822 script-src 'self' 'nonce-{nonce}'; \
1823 font-src 'self' data:; \
1824 object-src 'none'; \
1825 frame-ancestors {frame_ancestors_directive}"
1826 );
1827 h.insert(
1828 "Content-Security-Policy",
1829 HeaderValue::from_str(&csp).unwrap_or_else(|_| {
1830 HeaderValue::from_static(
1831 "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
1832 )
1833 }),
1834 );
1835 h.insert(
1836 "X-Permitted-Cross-Domain-Policies",
1837 HeaderValue::from_static("none"),
1838 );
1839 h.insert(
1840 "Permissions-Policy",
1841 HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
1842 );
1843 h.insert(
1844 "Cross-Origin-Opener-Policy",
1845 HeaderValue::from_static("same-origin"),
1846 );
1847 h.insert(
1848 "Cross-Origin-Resource-Policy",
1849 HeaderValue::from_static("same-origin"),
1850 );
1851 h.insert(
1854 "Cross-Origin-Embedder-Policy",
1855 HeaderValue::from_static("require-corp"),
1856 );
1857 if state.tls_enabled {
1858 h.insert(
1859 "Strict-Transport-Security",
1860 HeaderValue::from_static("max-age=31536000; includeSubDomains"),
1861 );
1862 }
1863 resp
1864}
1865
1866async fn csrf_protect(req: Request<Body>, next: Next) -> Response {
1880 use axum::http::Method;
1881
1882 let is_state_changing = matches!(
1883 *req.method(),
1884 Method::POST | Method::PUT | Method::PATCH | Method::DELETE
1885 );
1886 let path = req.uri().path();
1887 let has_token_auth = req.headers().contains_key("X-API-Key")
1888 || req
1889 .headers()
1890 .get(header::AUTHORIZATION)
1891 .and_then(|v| v.to_str().ok())
1892 .is_some_and(|v| v.starts_with("Bearer "));
1893
1894 if !is_state_changing || path.starts_with("/webhooks/") || has_token_auth {
1895 return next.run(req).await;
1896 }
1897
1898 let headers = req.headers();
1899 let header_str = |name: &header::HeaderName| {
1900 headers
1901 .get(name)
1902 .and_then(|v| v.to_str().ok())
1903 .map(str::to_owned)
1904 };
1905 let origin = header_str(&header::ORIGIN);
1906 let referer = header_str(&header::REFERER);
1907 let host = header_str(&header::HOST);
1908
1909 let authority_of = |url: &str| -> Option<String> {
1911 url.split_once("://")
1912 .map(|(_, rest)| rest.split('/').next().unwrap_or(rest).to_owned())
1913 };
1914
1915 let source_authority = origin
1916 .as_deref()
1917 .and_then(authority_of)
1918 .or_else(|| referer.as_deref().and_then(authority_of));
1919
1920 match (source_authority, host) {
1921 (None, _) => next.run(req).await,
1923 (Some(src), Some(h)) if src == h => next.run(req).await,
1924 (Some(src), host) => {
1925 tracing::warn!(
1926 event = "csrf_rejected",
1927 path = %path,
1928 origin = %src,
1929 host = ?host,
1930 "Cross-origin state-changing request rejected (CSRF guard)"
1931 );
1932 (
1933 StatusCode::FORBIDDEN,
1934 "403 Forbidden — cross-origin request rejected\n",
1935 )
1936 .into_response()
1937 }
1938 }
1939}
1940
1941fn page_fade_html(nonce: &str) -> String {
1949 const STYLE: &str = r"<style>
1958@keyframes sloc-page-fade-in{from{opacity:0;}to{opacity:1;}}
1959.page,.site-footer{animation:sloc-page-fade-in .3s ease-out;}
1960body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:0;transition:opacity .16s ease-in;animation:none;}
1961@media (prefers-reduced-motion:reduce){.page,.site-footer{animation:none;}body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:1;transition:none;}}
1962</style>";
1963 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');});})();";
1972 format!("{STYLE}<script nonce=\"{nonce}\">{JS}</script>")
1973}
1974
1975fn loading_overlay_block(nonce: &str, aria_label: &str) -> String {
1990 const TPL: &str = r#"<style nonce="__N__">
1991html.sloc-pending body{visibility:hidden;}
1992html.sloc-pending #rpt-loading-overlay{visibility:visible;}
1993#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%);}
1994#rpt-loading-overlay.fade-out{opacity:0;pointer-events:none;}
1995body.dark-theme #rpt-loading-overlay{background:radial-gradient(125% 125% at 50% 0%,#241810 0%,#1a120b 45%,#130c06 100%);}
1996body.pdf-mode #rpt-loading-overlay{display:none!important;}
1997.rpt-bg-blob{position:absolute;border-radius:50%;filter:blur(64px);opacity:.5;pointer-events:none;will-change:transform;}
1998.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;}
1999.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;}
2000@keyframes rpt-drift-a{0%,100%{transform:translate3d(0,0,0) scale(1);}50%{transform:translate3d(9vw,7vw,0) scale(1.18);}}
2001@keyframes rpt-drift-b{0%,100%{transform:translate3d(0,0,0) scale(1.06);}50%{transform:translate3d(-8vw,-6vw,0) scale(.88);}}
2002body.dark-theme .rpt-bg-blob{opacity:.36;}
2003.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;}
2004@keyframes rpt-card-in{from{opacity:0;transform:translateY(14px) scale(.96);}to{opacity:1;transform:none;}}
2005body.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);}
2006.rpt-load-logo{width:54px;height:54px;object-fit:contain;filter:drop-shadow(0 6px 16px rgba(90,48,12,.45));}
2007.rpt-spinner-wrap{position:relative;width:84px;height:84px;}
2008.rpt-spinner-track{position:absolute;inset:0;border-radius:50%;border:5px solid rgba(196,92,16,.12);}
2009.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));}
2010@keyframes rpt-spin{to{transform:rotate(360deg);}}
2011.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;}
2012body.dark-theme .rpt-spinner-track{border-color:rgba(196,92,16,.2);}
2013body.dark-theme .rpt-spinner-pct{color:#e8932f;}
2014.rpt-loading-text{font-size:15px;font-weight:600;letter-spacing:.08em;display:flex;align-items:baseline;gap:2px;}
2015.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;}
2016@keyframes rpt-text-shimmer{to{background-position:-220% center;}}
2017.rpt-dot{display:inline-block;color:#c45c10;-webkit-text-fill-color:#c45c10;animation:rpt-bounce 1.7s ease-in-out infinite;opacity:0;}
2018.rpt-dot:nth-child(2){animation-delay:.28s;}
2019.rpt-dot:nth-child(3){animation-delay:.56s;}
2020@keyframes rpt-bounce{0%,60%,100%{opacity:0;transform:translateY(0);}30%{opacity:1;transform:translateY(-5px);}}
2021.rpt-status{font-size:12.5px;font-weight:600;letter-spacing:.02em;color:var(--muted,#8a7060);min-height:16px;text-align:center;}
2022.rpt-progress{width:100%;height:6px;border-radius:99px;background:rgba(196,92,16,.12);overflow:hidden;}
2023.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;}
2024body.dark-theme .rpt-progress{background:rgba(196,92,16,.2);}
2025@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;}}
2026</style>
2027<noscript><style nonce="__N__">html.sloc-pending body{visibility:visible!important;}#rpt-loading-overlay{display:none!important;}</style></noscript>
2028<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>
2029<div id="rpt-loading-overlay" aria-live="polite" aria-label="__LABEL__">
2030 <div class="rpt-bg-blob rpt-blob-a" aria-hidden="true"></div>
2031 <div class="rpt-bg-blob rpt-blob-b" aria-hidden="true"></div>
2032 <div class="rpt-load-card">
2033 <img src="/images/logo/small-logo.png" alt="oxide-sloc" class="rpt-load-logo" />
2034 <div class="rpt-spinner-wrap">
2035 <div class="rpt-spinner-track"></div>
2036 <div class="rpt-spinner"></div>
2037 <div class="rpt-spinner-pct" id="rpt-pct">0%</div>
2038 </div>
2039 <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>
2040 <div class="rpt-status" id="rpt-status">__LABEL__</div>
2041 <div class="rpt-progress"><div class="rpt-progress-bar" id="rpt-progress-bar"></div></div>
2042 </div>
2043</div>
2044<script nonce="__N__">
2045(function(){
2046 var ov=document.getElementById('rpt-loading-overlay');
2047 var root=document.documentElement;
2048 function reveal(){root.classList.remove('sloc-pending');}
2049 if(!ov){reveal();return;}
2050 var bar=document.getElementById('rpt-progress-bar'),pct=document.getElementById('rpt-pct'),statusEl=document.getElementById('rpt-status');
2051 var msgs=['__LABEL__','Reading baseline scan','Reading current scan','Computing line deltas','Building file matrix','Rendering charts'];
2052 var mi=0,prog=0,done=false,start=Date.now();
2053 // MIN: minimum time the overlay stays up. SETTLE: extra buffer after the page
2054 // reports ready so the final chart paint completes. CHART_CAP: stop waiting on
2055 // charts after this. HARD_CAP: absolute backstop so the overlay can never stick.
2056 var MIN=1200,SETTLE=750,CHART_CAP=12000,HARD_CAP=25000;
2057 function setProg(p){prog=p;if(bar)bar.style.transform='scaleX('+(p/100).toFixed(3)+')';if(pct)pct.textContent=Math.round(p)+'%';}
2058 function nextMsg(){if(statusEl)statusEl.textContent=msgs[mi%msgs.length];mi++;}
2059 setProg(8);
2060 var msgTimer=setInterval(nextMsg,700);
2061 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);
2062 // These pages draw charts into known SVG containers that start empty and are
2063 // filled by JS once layout is available (some only after a ResizeObserver pass
2064 // post-`load`). Treat the page as ready only once every chart container present
2065 // actually has rendered content, so the overlay never lifts on a half-drawn page.
2066 function chartsRendered(){
2067 var sel=['#cmp-tl-svg','#mc-chart'];
2068 for(var i=0;i<sel.length;i++){var el=document.querySelector(sel[i]);if(el&&!el.firstChild)return false;}
2069 return true;
2070 }
2071 function finish(){
2072 if(done)return;done=true;
2073 clearInterval(msgTimer);clearInterval(progTimer);setProg(100);if(statusEl)statusEl.textContent='Done';
2074 // Reveal the fully-rendered page under the still-opaque overlay, let it paint
2075 // for two frames, THEN fade the overlay — so no half-rendered state is shown.
2076 reveal();
2077 requestAnimationFrame(function(){requestAnimationFrame(function(){
2078 setTimeout(function(){ov.classList.add('fade-out');setTimeout(function(){if(ov.parentNode)ov.parentNode.removeChild(ov);},480);},80);
2079 });});
2080 }
2081 // Wait for `load` (resources + first layout), then poll until the charts have
2082 // actually rendered (or the chart cap), then hold for MIN + SETTLE before fading.
2083 function afterLoad(){
2084 var loadAt=Date.now();
2085 (function poll(){
2086 if(done)return;
2087 if(chartsRendered()||Date.now()-loadAt>=CHART_CAP){
2088 setTimeout(finish,Math.max(MIN-(Date.now()-start),0)+SETTLE);
2089 return;
2090 }
2091 requestAnimationFrame(poll);
2092 })();
2093 }
2094 if(document.readyState==='complete')afterLoad();else window.addEventListener('load',afterLoad);
2095 // Absolute safety net: never let the gate/overlay get stuck.
2096 setTimeout(function(){if(!done)finish();},HARD_CAP);
2097})();
2098</script>"#;
2099 TPL.replace("__N__", nonce).replace("__LABEL__", aria_label)
2100}
2101
2102fn sloc_toast_assets(nonce: &str) -> String {
2117 const TPL: &str = r#"<style nonce="__N__">
2118#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;}
2119.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);}
2120.sloc-toast.sloc-toast-in{opacity:1;transform:none;}
2121.sloc-toast.sloc-toast-out{opacity:0;transform:translateY(8px) scale(.97);}
2122.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;}
2123.sloc-toast-success .sloc-toast-ico{background:#2a6846;}
2124.sloc-toast-error .sloc-toast-ico{background:#b23030;}
2125.sloc-toast-info .sloc-toast-ico{background:#c45c10;}
2126.sloc-toast-success{border-color:#bfe0cc;}
2127.sloc-toast-error{border-color:#e6b3b3;}
2128.sloc-toast-msg{flex:1 1 auto;padding-top:1px;word-break:break-word;}
2129.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;}
2130@keyframes sloc-toast-spin{to{transform:rotate(360deg);}}
2131.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;}
2132.sloc-toast-x:hover{opacity:1;}
2133body.dark-theme .sloc-toast{background:#241a12;color:#f0e6dc;border-color:#3a2c20;box-shadow:0 12px 32px rgba(0,0,0,.5);}
2134body.dark-theme .sloc-toast-success{border-color:#2f5a44;}
2135body.dark-theme .sloc-toast-error{border-color:#6e3434;}
2136body.dark-theme .sloc-toast-spin{border-color:rgba(232,147,47,.25);border-top-color:#e8932f;}
2137@media (prefers-reduced-motion:reduce){.sloc-toast{transition:opacity .2s ease;transform:none!important;}}
2138</style>
2139<script nonce="__N__">
2140(function(){
2141 if(window.slocToast)return;
2142 function wrap(){
2143 var w=document.getElementById('sloc-toast-wrap');
2144 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);}
2145 return w;
2146 }
2147 window.slocToast=function(msg,opts){
2148 opts=opts||{};
2149 var type=opts.type||'info';
2150 var loading=type==='loading';
2151 var t=document.createElement('div');
2152 t.className='sloc-toast sloc-toast-'+(loading?'info':type);
2153 t.setAttribute('role',type==='error'?'alert':'status');
2154 var ico=loading
2155 ? '<span class="sloc-toast-spin" aria-hidden="true"></span>'
2156 : '<span class="sloc-toast-ico" aria-hidden="true">'+(type==='success'?'✓':type==='error'?'✕':'i')+'</span>';
2157 t.innerHTML=ico+'<span class="sloc-toast-msg"></span><button type="button" class="sloc-toast-x" aria-label="Dismiss">×</button>';
2158 t.querySelector('.sloc-toast-msg').textContent=String(msg);
2159 wrap().appendChild(t);
2160 requestAnimationFrame(function(){t.classList.add('sloc-toast-in');});
2161 var gone=false,timer=null;
2162 function close(){
2163 if(gone)return;gone=true;if(timer)clearTimeout(timer);
2164 t.classList.remove('sloc-toast-in');t.classList.add('sloc-toast-out');
2165 setTimeout(function(){if(t.parentNode)t.parentNode.removeChild(t);},300);
2166 }
2167 t.querySelector('.sloc-toast-x').addEventListener('click',close);
2168 var ttl=opts.duration!=null?opts.duration:(type==='error'?7000:loading?0:4500);
2169 if(ttl>0)timer=setTimeout(close,ttl);
2170 return {dismiss:close,el:t};
2171 };
2172 window.slocExportPdf=function(o){
2173 o=o||{};
2174 var btn=o.button||null,orig=btn?btn.innerHTML:'',fname=o.filename||'report.pdf';
2175 if(btn&&btn.disabled)return;
2176 if(btn){btn.disabled=true;btn.style.opacity='0.55';btn.style.cursor='not-allowed';btn.textContent='Generating PDF…';}
2177 var load=window.slocToast('Generating PDF… this can take a few seconds.',{type:'loading'});
2178 return fetch('/export/pdf',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({html:o.html,filename:fname})})
2179 .then(function(r){if(!r.ok)throw new Error('server returned '+r.status);return r.blob();})
2180 .then(function(blob){
2181 var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;
2182 document.body.appendChild(a);a.click();document.body.removeChild(a);
2183 setTimeout(function(){URL.revokeObjectURL(a.href);},400);
2184 load.dismiss();
2185 window.slocToast('PDF exported — '+fname+' saved to your local disk.',{type:'success'});
2186 })
2187 .catch(function(e){
2188 load.dismiss();
2189 window.slocToast('PDF export failed: '+e.message+'. A Chromium-based browser (Chrome/Edge/Brave) must be installed on the server.',{type:'error'});
2190 })
2191 .finally(function(){if(btn){btn.disabled=false;btn.style.opacity='';btn.style.cursor='';btn.innerHTML=orig;}});
2192 };
2193})();
2194</script>"#;
2195 TPL.replace("__N__", nonce)
2196}
2197
2198async fn inject_page_fade_into_html(resp: &mut Response, nonce: &str) {
2203 let is_html = resp
2204 .headers()
2205 .get(header::CONTENT_TYPE)
2206 .and_then(|v| v.to_str().ok())
2207 .is_some_and(|v| v.starts_with("text/html"));
2208 if !is_html {
2209 return;
2210 }
2211 let body = std::mem::replace(resp.body_mut(), Body::empty());
2212 let Ok(bytes) = axum::body::to_bytes(body, usize::MAX).await else {
2213 return;
2214 };
2215 let html = match String::from_utf8(bytes.to_vec()) {
2216 Ok(s) => s,
2217 Err(e) => {
2218 *resp.body_mut() = Body::from(e.into_bytes());
2219 return;
2220 }
2221 };
2222 if html.contains("id=\"rpt-loading-overlay\"") {
2223 *resp.body_mut() = Body::from(html);
2224 return;
2225 }
2226 let insert_at = html
2230 .find("<body")
2231 .and_then(|bi| html[bi..].find('>').map(|g| bi + g + 1))
2232 .or_else(|| {
2233 let lower = html.to_ascii_lowercase();
2234 lower
2235 .find("<body")
2236 .and_then(|bi| lower[bi..].find('>').map(|g| bi + g + 1))
2237 });
2238 let new_html = match insert_at {
2239 Some(at) => {
2240 let mut out = String::with_capacity(html.len() + 1024);
2241 out.push_str(&html[..at]);
2242 out.push_str(&page_fade_html(nonce));
2243 out.push_str(&html[at..]);
2244 out
2245 }
2246 None => html,
2247 };
2248 resp.headers_mut().remove(header::CONTENT_LENGTH);
2249 *resp.body_mut() = Body::from(new_html);
2250}
2251
2252async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
2253 let peer_ip = req
2254 .extensions()
2255 .get::<axum::extract::ConnectInfo<SocketAddr>>()
2256 .map(|c| c.0.ip());
2257
2258 let ip = peer_ip
2262 .and_then(|peer| {
2263 if state.trust_proxy && state.trusted_proxy_ips.contains(&peer) {
2264 req.headers()
2265 .get("X-Forwarded-For")
2266 .and_then(|v| v.to_str().ok())
2267 .and_then(|s| s.split(',').next())
2268 .and_then(|s| s.trim().parse::<IpAddr>().ok())
2269 } else {
2270 None
2271 }
2272 })
2273 .or(peer_ip)
2274 .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
2275
2276 if !state.rate_limiter.is_allowed(ip) {
2277 tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
2278 path = %req.uri().path(), "Rate limit exceeded");
2279 return (
2280 StatusCode::TOO_MANY_REQUESTS,
2281 [(header::RETRY_AFTER, "60")],
2282 "429 Too Many Requests\n",
2283 )
2284 .into_response();
2285 }
2286 next.run(req).await
2287}
2288
2289async fn splash(
2290 State(state): State<AppState>,
2291 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2292) -> impl IntoResponse {
2293 let lan_ip = if state.server_mode {
2294 primary_lan_ip()
2295 } else {
2296 None
2297 };
2298 let port = state
2299 .base_config
2300 .web
2301 .bind_address
2302 .rsplit(':')
2303 .next()
2304 .and_then(|p| p.parse::<u16>().ok())
2305 .unwrap_or(4317);
2306 let has_api_key = !state.api_keys.is_empty();
2307 let template = SplashTemplate {
2308 csp_nonce,
2309 server_mode: state.server_mode,
2310 lan_ip,
2311 port,
2312 version: env!("CARGO_PKG_VERSION"),
2313 has_api_key,
2314 };
2315 Html(
2316 template
2317 .render()
2318 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2319 )
2320}
2321
2322async fn index(
2323 State(state): State<AppState>,
2324 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2325 Query(query): Query<IndexQuery>,
2326) -> impl IntoResponse {
2327 let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
2328 let policy = query
2329 .mixed_line_policy
2330 .unwrap_or_else(|| "code_only".to_string());
2331 let behavior = query
2332 .binary_file_behavior
2333 .unwrap_or_else(|| "skip".to_string());
2334 let cfg = ScanConfig {
2335 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
2336 path: query.path.unwrap_or_default(),
2337 include_globs: query.include_globs.unwrap_or_default(),
2338 exclude_globs: query.exclude_globs.unwrap_or_default(),
2339 submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
2340 mixed_line_policy: policy,
2341 python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
2342 != Some("off"),
2343 generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
2344 minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
2345 vendor_directory_detection: query.vendor_directory_detection.as_deref()
2346 != Some("disabled"),
2347 include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
2348 binary_file_behavior: behavior,
2349 output_dir: query.output_dir.unwrap_or_default(),
2350 report_title: query.report_title.unwrap_or_default(),
2351 continuation_line_policy: query
2352 .continuation_line_policy
2353 .unwrap_or_else(default_each_physical_line),
2354 blank_in_block_comment_policy: query
2355 .blank_in_block_comment_policy
2356 .unwrap_or_else(default_count_as_comment),
2357 count_compiler_directives: query.count_compiler_directives.as_deref()
2358 != Some("disabled"),
2359 style_analysis_enabled: query.style_analysis_enabled.as_deref() != Some("disabled"),
2360 style_col_threshold: query
2361 .style_col_threshold
2362 .as_deref()
2363 .and_then(|s| s.parse().ok())
2364 .unwrap_or(80),
2365 style_score_threshold: query
2366 .style_score_threshold
2367 .as_deref()
2368 .and_then(|s| s.parse().ok())
2369 .unwrap_or(0),
2370 style_lang_scope: query.style_lang_scope.unwrap_or_else(default_all_scope),
2371 coverage_file: query.coverage_file.unwrap_or_default(),
2372 cocomo_mode: query.cocomo_mode.unwrap_or_else(default_organic),
2373 complexity_alert: query
2374 .complexity_alert
2375 .as_deref()
2376 .and_then(|s| s.parse().ok())
2377 .unwrap_or(0),
2378 exclude_duplicates: query.exclude_duplicates.as_deref() == Some("enabled"),
2379 activity_window: query
2380 .activity_window
2381 .as_deref()
2382 .and_then(|s| s.parse().ok())
2383 .unwrap_or(90),
2384 };
2385 serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
2386 } else {
2387 "{}".to_string()
2388 };
2389
2390 let git_repo = query.git_repo.unwrap_or_default();
2391 let git_ref = query.git_ref.unwrap_or_default();
2392
2393 let git_label = make_git_label(&git_repo, &git_ref);
2394 let git_output_dir = if git_label.is_empty() {
2395 String::new()
2396 } else {
2397 desktop_dir().join(&git_label).display().to_string()
2398 };
2399 let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
2400 let git_output_dir_json =
2401 serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
2402
2403 let template = IndexTemplate {
2404 version: env!("CARGO_PKG_VERSION"),
2405 prefill_json,
2406 csp_nonce,
2407 git_repo,
2408 git_ref,
2409 git_label_json,
2410 git_output_dir_json,
2411 server_mode: state.server_mode,
2412 };
2413
2414 Html(
2415 template
2416 .render()
2417 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2418 )
2419}
2420
2421async fn scan_setup_handler(
2422 State(state): State<AppState>,
2423 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2424) -> impl IntoResponse {
2425 let recent_scans_json = {
2426 let arr: Vec<serde_json::Value> = {
2427 let reg = state.registry.lock().await;
2428 reg.entries
2429 .iter()
2430 .rev()
2431 .take(6)
2432 .map(|e| {
2433 let run_dir = e
2434 .html_path
2435 .as_ref()
2436 .or(e.json_path.as_ref())
2437 .and_then(|p| p.parent().map(PathBuf::from));
2438 let config_val: Option<serde_json::Value> = run_dir
2439 .and_then(|d| find_scan_config_in_dir(&d))
2440 .and_then(|p| fs::read_to_string(&p).ok())
2441 .and_then(|s| serde_json::from_str(&s).ok());
2442 serde_json::json!({
2443 "project_label": e.project_label,
2444 "timestamp": fmt_la_time(e.timestamp_utc),
2445 "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
2446 "config": config_val,
2447 })
2448 })
2449 .collect()
2450 };
2451 serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
2452 };
2453
2454 let template = ScanSetupTemplate {
2455 version: env!("CARGO_PKG_VERSION"),
2456 recent_scans_json,
2457 csp_nonce,
2458 };
2459 Html(
2460 template
2461 .render()
2462 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
2463 )
2464}
2465
2466async fn healthz() -> &'static str {
2467 "ok"
2468}
2469
2470async fn api_version_handler() -> impl IntoResponse {
2471 axum::Json(serde_json::json!({
2472 "name": "oxide-sloc",
2473 "version": env!("CARGO_PKG_VERSION"),
2474 }))
2475}
2476
2477fn prom_runs_total() -> &'static prometheus::IntCounter {
2480 static COUNTER: OnceLock<prometheus::IntCounter> = OnceLock::new();
2481 COUNTER.get_or_init(|| {
2482 prometheus::register_int_counter!(
2483 "oxide_sloc_runs_total",
2484 "Total number of completed analysis runs"
2485 )
2486 .expect("failed to register oxide_sloc_runs_total counter")
2487 })
2488}
2489
2490async fn metrics_handler() -> impl IntoResponse {
2491 use prometheus::Encoder as _;
2492 let mut buf = Vec::new();
2493 let encoder = prometheus::TextEncoder::new();
2494 let _ = encoder.encode(&prometheus::gather(), &mut buf);
2495 (
2496 [(
2497 axum::http::header::CONTENT_TYPE,
2498 "text/plain; version=0.0.4; charset=utf-8",
2499 )],
2500 buf,
2501 )
2502}
2503
2504static OPENAPI_YAML: &str = include_str!("../assets/openapi.yaml");
2505
2506async fn openapi_yaml_handler() -> impl IntoResponse {
2507 (
2508 [(axum::http::header::CONTENT_TYPE, "application/yaml")],
2509 OPENAPI_YAML,
2510 )
2511}
2512
2513static LLMS_TXT: &str = include_str!("../assets/ai/llms.txt");
2514static LLMS_FULL_TXT: &str = include_str!("../assets/ai/llms-full.txt");
2515
2516async fn llms_txt_handler() -> impl IntoResponse {
2517 (
2518 [
2519 (
2520 axum::http::header::CONTENT_TYPE,
2521 "text/plain; charset=utf-8",
2522 ),
2523 (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
2524 ],
2525 LLMS_TXT,
2526 )
2527}
2528
2529async fn llms_full_txt_handler() -> impl IntoResponse {
2530 (
2531 [
2532 (
2533 axum::http::header::CONTENT_TYPE,
2534 "text/plain; charset=utf-8",
2535 ),
2536 (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
2537 ],
2538 LLMS_FULL_TXT,
2539 )
2540}
2541
2542async fn api_docs_handler(
2543 State(state): State<AppState>,
2544 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
2545) -> impl IntoResponse {
2546 let has_api_key = !state.api_keys.is_empty();
2547 Html(
2548 ApiDocsTemplate {
2549 has_api_key,
2550 csp_nonce,
2551 version: env!("CARGO_PKG_VERSION"),
2552 }
2553 .render()
2554 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
2555 )
2556}
2557
2558async fn chart_js_handler() -> impl IntoResponse {
2559 (
2560 [
2561 (
2562 header::CONTENT_TYPE,
2563 "application/javascript; charset=utf-8",
2564 ),
2565 (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
2566 ],
2567 CHART_JS,
2568 )
2569}
2570
2571async fn report_chart_js_handler() -> impl IntoResponse {
2572 (
2573 [
2574 (
2575 header::CONTENT_TYPE,
2576 "application/javascript; charset=utf-8",
2577 ),
2578 (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
2579 ],
2580 REPORT_CHART_JS,
2581 )
2582}
2583
2584#[derive(Debug, Deserialize)]
2585struct AnalyzeForm {
2586 path: String,
2587 git_repo: Option<String>,
2588 git_ref: Option<String>,
2589 mixed_line_policy: Option<MixedLinePolicy>,
2590 python_docstrings_as_comments: Option<String>,
2591 generated_file_detection: Option<String>,
2592 minified_file_detection: Option<String>,
2593 vendor_directory_detection: Option<String>,
2594 include_lockfiles: Option<String>,
2595 binary_file_behavior: Option<BinaryFileBehavior>,
2596 output_dir: Option<String>,
2597 report_title: Option<String>,
2598 report_header_footer: Option<String>,
2599 include_globs: Option<String>,
2600 exclude_globs: Option<String>,
2601 submodule_breakdown: Option<String>,
2602 coverage_file: Option<String>,
2603 continuation_line_policy: Option<ContinuationLinePolicy>,
2604 blank_in_block_comment_policy: Option<BlankInBlockCommentPolicy>,
2605 count_compiler_directives: Option<String>,
2606 style_col_threshold: Option<String>,
2607 style_analysis_enabled: Option<String>,
2608 style_score_threshold: Option<String>,
2609 style_lang_scope: Option<String>,
2610 cocomo_mode: Option<String>,
2612 complexity_alert: Option<String>,
2614 exclude_duplicates: Option<String>,
2616 activity_window: Option<String>,
2618}
2619
2620#[allow(clippy::struct_excessive_bools)]
2621#[derive(Debug, Serialize, Deserialize, Clone)]
2622struct ScanConfig {
2623 oxide_sloc_version: String,
2624 path: String,
2625 include_globs: String,
2626 exclude_globs: String,
2627 submodule_breakdown: bool,
2628 mixed_line_policy: String,
2629 python_docstrings_as_comments: bool,
2630 generated_file_detection: bool,
2631 minified_file_detection: bool,
2632 vendor_directory_detection: bool,
2633 include_lockfiles: bool,
2634 binary_file_behavior: String,
2635 output_dir: String,
2636 report_title: String,
2637 #[serde(default = "default_each_physical_line")]
2639 continuation_line_policy: String,
2640 #[serde(default = "default_count_as_comment")]
2641 blank_in_block_comment_policy: String,
2642 #[serde(default = "default_true_bool")]
2643 count_compiler_directives: bool,
2644 #[serde(default = "default_true_bool")]
2645 style_analysis_enabled: bool,
2646 #[serde(default = "default_style_col_threshold")]
2647 style_col_threshold: u16,
2648 #[serde(default)]
2649 style_score_threshold: u8,
2650 #[serde(default = "default_all_scope")]
2651 style_lang_scope: String,
2652 #[serde(default)]
2653 coverage_file: String,
2654 #[serde(default = "default_organic")]
2655 cocomo_mode: String,
2656 #[serde(default)]
2657 complexity_alert: u32,
2658 #[serde(default)]
2659 exclude_duplicates: bool,
2660 #[serde(default = "default_activity_window")]
2662 activity_window: u32,
2663}
2664
2665const fn default_activity_window() -> u32 {
2666 90
2667}
2668
2669fn default_each_physical_line() -> String {
2670 "each_physical_line".to_string()
2671}
2672fn default_count_as_comment() -> String {
2673 "count_as_comment".to_string()
2674}
2675const fn default_true_bool() -> bool {
2676 true
2677}
2678const fn default_style_col_threshold() -> u16 {
2679 80
2680}
2681fn default_all_scope() -> String {
2682 "all".to_string()
2683}
2684fn default_organic() -> String {
2685 "organic".to_string()
2686}
2687
2688#[derive(Debug, Deserialize, Default)]
2689struct IndexQuery {
2690 path: Option<String>,
2691 include_globs: Option<String>,
2692 exclude_globs: Option<String>,
2693 submodule_breakdown: Option<String>,
2694 mixed_line_policy: Option<String>,
2695 python_docstrings_as_comments: Option<String>,
2696 generated_file_detection: Option<String>,
2697 minified_file_detection: Option<String>,
2698 vendor_directory_detection: Option<String>,
2699 include_lockfiles: Option<String>,
2700 binary_file_behavior: Option<String>,
2701 output_dir: Option<String>,
2702 report_title: Option<String>,
2703 prefilled: Option<String>,
2704 git_repo: Option<String>,
2705 git_ref: Option<String>,
2706 continuation_line_policy: Option<String>,
2708 blank_in_block_comment_policy: Option<String>,
2709 count_compiler_directives: Option<String>,
2710 style_analysis_enabled: Option<String>,
2711 style_col_threshold: Option<String>,
2712 style_score_threshold: Option<String>,
2713 style_lang_scope: Option<String>,
2714 coverage_file: Option<String>,
2715 cocomo_mode: Option<String>,
2716 complexity_alert: Option<String>,
2717 exclude_duplicates: Option<String>,
2718 activity_window: Option<String>,
2719}
2720
2721#[derive(Debug, Deserialize)]
2722struct PreviewQuery {
2723 path: Option<String>,
2724 include_globs: Option<String>,
2725 exclude_globs: Option<String>,
2726}
2727
2728#[cfg(feature = "native-dialog")]
2729#[derive(Debug, Deserialize)]
2730struct PickDirectoryQuery {
2731 kind: Option<String>,
2732 current: Option<String>,
2733}
2734
2735#[cfg(not(feature = "native-dialog"))]
2736#[derive(Debug, Deserialize)]
2737struct PickDirectoryQuery {}
2738
2739#[derive(Debug, Deserialize, Default)]
2740struct ArtifactQuery {
2741 download: Option<String>,
2742}
2743
2744#[cfg(feature = "native-dialog")]
2745#[derive(Debug, Serialize)]
2746struct PickDirectoryResponse {
2747 selected_path: Option<String>,
2748 cancelled: bool,
2749}
2750
2751#[cfg(feature = "native-dialog")]
2752async fn pick_directory_handler(
2753 State(state): State<AppState>,
2754 Query(query): Query<PickDirectoryQuery>,
2755) -> Response {
2756 if state.server_mode {
2757 return StatusCode::NOT_FOUND.into_response();
2758 }
2759 if std::env::var("SLOC_HEADLESS").is_ok() {
2761 return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
2762 .into_response();
2763 }
2764
2765 let is_coverage = query.kind.as_deref() == Some("coverage");
2766 let title = match query.kind.as_deref() {
2767 Some("output") => "Select output directory",
2768 Some("reports") => "Select folder containing saved reports",
2769 Some("coverage") => "Select LCOV coverage file",
2770 _ => "Select project directory",
2771 }
2772 .to_owned();
2773 let current = query.current.clone();
2774
2775 let picked = tokio::task::spawn_blocking(move || {
2776 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2779 let fg_tid = win_dialog_focus::attach_to_foreground();
2780 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2781 win_dialog_focus::flash_dialog_when_ready(title.clone());
2782
2783 let mut dialog = rfd::FileDialog::new().set_title(&title);
2784 if let Some(current) = current.as_deref() {
2785 let resolved = resolve_input_path(current);
2786 let seed = if resolved.is_dir() {
2787 Some(resolved)
2788 } else {
2789 resolved.parent().map(Path::to_path_buf)
2790 };
2791 if let Some(seed_dir) = seed.filter(|p| p.exists()) {
2792 dialog = dialog.set_directory(seed_dir);
2793 }
2794 }
2795 let result = if is_coverage {
2796 dialog
2797 .add_filter(
2798 "Coverage files (LCOV, Cobertura/JaCoCo XML, coverage.py/Istanbul JSON)",
2799 &["info", "lcov", "xml", "json"],
2800 )
2801 .pick_file()
2802 } else {
2803 dialog.pick_folder()
2804 };
2805
2806 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2807 win_dialog_focus::detach_from_foreground(fg_tid);
2808
2809 result
2810 })
2811 .await
2812 .unwrap_or(None);
2813
2814 Json(PickDirectoryResponse {
2815 selected_path: picked.as_ref().map(|p| display_path(p)),
2816 cancelled: picked.is_none(),
2817 })
2818 .into_response()
2819}
2820
2821#[cfg(not(feature = "native-dialog"))]
2822async fn pick_directory_handler(
2823 State(_state): State<AppState>,
2824 Query(_query): Query<PickDirectoryQuery>,
2825) -> Response {
2826 Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
2827}
2828
2829#[cfg(feature = "native-dialog")]
2830async fn pick_file_handler(State(state): State<AppState>) -> Response {
2831 if state.server_mode {
2832 return StatusCode::NOT_FOUND.into_response();
2833 }
2834 if std::env::var("SLOC_HEADLESS").is_ok() {
2835 return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
2836 .into_response();
2837 }
2838 let picked = tokio::task::spawn_blocking(|| {
2839 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2840 let fg_tid = win_dialog_focus::attach_to_foreground();
2841 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2842 win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
2843
2844 let result = rfd::FileDialog::new()
2845 .set_title("Select HTML report")
2846 .add_filter("HTML report", &["html"])
2847 .pick_file();
2848
2849 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
2850 win_dialog_focus::detach_from_foreground(fg_tid);
2851
2852 result
2853 })
2854 .await
2855 .unwrap_or(None);
2856 Json(PickDirectoryResponse {
2857 selected_path: picked.as_ref().map(|p| display_path(p)),
2858 cancelled: picked.is_none(),
2859 })
2860 .into_response()
2861}
2862
2863#[cfg(not(feature = "native-dialog"))]
2864async fn pick_file_handler(State(_state): State<AppState>) -> Response {
2865 Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
2866}
2867
2868fn is_upload_tmp_path(path: &Path) -> bool {
2873 let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
2874 path.starts_with(&upload_root)
2875}
2876
2877fn is_sample_path(path: &Path) -> bool {
2880 let root = workspace_root();
2881 path.starts_with(root.join("tests").join("fixtures")) || path.starts_with(root.join("samples"))
2882}
2883
2884fn upload_base_dir() -> PathBuf {
2886 std::env::temp_dir().join("oxide-sloc-uploads")
2887}
2888
2889fn upload_staging_path(id: &str) -> PathBuf {
2891 upload_base_dir().join(id)
2892}
2893
2894#[allow(clippy::result_large_err)] fn validate_upload_dir_request(body: &UploadDirRequest) -> Result<(), Response> {
2898 const MAX_FILES: usize = 50_000;
2899 if body.files.is_empty() {
2900 return Err((
2901 StatusCode::BAD_REQUEST,
2902 Json(serde_json::json!({"error": "No files received"})),
2903 )
2904 .into_response());
2905 }
2906 if body.files.len() > MAX_FILES {
2907 return Err((
2908 StatusCode::PAYLOAD_TOO_LARGE,
2909 Json(serde_json::json!({"error": "Too many files (limit 50 000)"})),
2910 )
2911 .into_response());
2912 }
2913 Ok(())
2914}
2915
2916fn resolve_or_create_staging(id: Option<&str>) -> (String, PathBuf) {
2919 match id {
2920 Some(id)
2921 if !id.is_empty()
2922 && id.len() <= 36
2923 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') =>
2924 {
2925 (id.to_string(), upload_staging_path(id))
2926 }
2927 _ => {
2928 let new_id = uuid::Uuid::new_v4().to_string();
2929 let staging = upload_staging_path(&new_id);
2930 (new_id, staging)
2931 }
2932 }
2933}
2934
2935#[allow(clippy::result_large_err)]
2940async fn stage_decoded_entry(
2941 entry: &UploadedFile,
2942 staging: &Path,
2943 total_bytes: &mut usize,
2944 project_root: &mut Option<PathBuf>,
2945) -> Result<(), Response> {
2946 const MAX_TOTAL_BYTES: usize = 500 * 1024 * 1024;
2947
2948 let Ok(data) = base64::Engine::decode(
2949 &base64::engine::general_purpose::STANDARD,
2950 entry.content.as_bytes(),
2951 ) else {
2952 return Ok(());
2953 };
2954
2955 *total_bytes += data.len();
2956 if *total_bytes > MAX_TOTAL_BYTES {
2957 return Err((
2958 StatusCode::PAYLOAD_TOO_LARGE,
2959 Json(serde_json::json!({"error": "Upload exceeds the 500 MB limit"})),
2960 )
2961 .into_response());
2962 }
2963
2964 let rel = std::path::Path::new(&entry.path);
2965 if project_root.is_none()
2966 && let Some(first) = rel.components().next()
2967 {
2968 *project_root = Some(staging.join(first.as_os_str()));
2969 }
2970
2971 let dest = staging.join(rel);
2972 if let Some(parent) = dest.parent()
2973 && tokio::fs::create_dir_all(parent).await.is_err()
2974 {
2975 return Err((
2976 StatusCode::INTERNAL_SERVER_ERROR,
2977 Json(serde_json::json!({"error": "Failed to create directory structure"})),
2978 )
2979 .into_response());
2980 }
2981
2982 if tokio::fs::write(&dest, &data).await.is_err() {
2983 return Err((
2984 StatusCode::INTERNAL_SERVER_ERROR,
2985 Json(serde_json::json!({"error": "Failed to write uploaded file"})),
2986 )
2987 .into_response());
2988 }
2989
2990 Ok(())
2991}
2992
2993async fn write_upload_files(
2997 files: &[UploadedFile],
2998 staging: &Path,
2999 upload_id: &str,
3000) -> Result<(usize, Option<PathBuf>), Response> {
3001 let mut total_bytes: usize = 0;
3002 let mut project_root: Option<PathBuf> = None;
3003
3004 for entry in files {
3005 let rel = std::path::Path::new(&entry.path);
3006 if rel
3007 .components()
3008 .any(|c| matches!(c, std::path::Component::ParentDir))
3009 {
3010 let _ = tokio::fs::remove_dir_all(staging).await;
3012 tracing::warn!(
3013 event = "upload_path_traversal",
3014 upload_id = %upload_id,
3015 path = %entry.path,
3016 "Upload rejected: path traversal component detected"
3017 );
3018 return Err((
3019 StatusCode::BAD_REQUEST,
3020 Json(serde_json::json!({"error": "Upload rejected: path traversal detected"})),
3021 )
3022 .into_response());
3023 }
3024
3025 if let Err(resp) =
3026 stage_decoded_entry(entry, staging, &mut total_bytes, &mut project_root).await
3027 {
3028 let _ = tokio::fs::remove_dir_all(staging).await;
3029 return Err(resp);
3030 }
3031 }
3032
3033 Ok((files.len(), project_root))
3034}
3035
3036fn parse_tarball_size_caps() -> (u64, u64) {
3039 let compressed = std::env::var("SLOC_MAX_TARBALL_MB")
3040 .ok()
3041 .and_then(|v| v.parse().ok())
3042 .unwrap_or(2048_u64)
3043 * 1024
3044 * 1024;
3045 let decompressed = std::env::var("SLOC_MAX_TARBALL_DECOMPRESSED_MB")
3046 .ok()
3047 .and_then(|v| v.parse().ok())
3048 .unwrap_or(10_240_u64)
3049 * 1024
3050 * 1024;
3051 (compressed, decompressed)
3052}
3053
3054fn tarball_http_body_limit_bytes() -> usize {
3058 std::env::var("SLOC_MAX_TARBALL_MB")
3059 .ok()
3060 .and_then(|v| v.parse::<usize>().ok())
3061 .unwrap_or(2048)
3062 .saturating_mul(1024 * 1024)
3063}
3064
3065#[allow(clippy::result_large_err)] async fn stream_body_to_file(
3070 body: axum::body::Body,
3071 dest_path: &Path,
3072 max_bytes: u64,
3073) -> Result<u64, Response> {
3074 use http_body_util::BodyExt as _;
3075 use tokio::io::AsyncWriteExt as _;
3076
3077 let mut file = match tokio::fs::File::create(dest_path).await {
3078 Ok(f) => f,
3079 Err(e) => {
3080 tracing::error!(
3081 event = "upload_io_error",
3082 "failed to create tarball temp file: {e}"
3083 );
3084 return Err((
3085 StatusCode::INTERNAL_SERVER_ERROR,
3086 Json(serde_json::json!({"error": "Upload initialization failed"})),
3087 )
3088 .into_response());
3089 }
3090 };
3091
3092 let mut body = body;
3093 let mut written: u64 = 0;
3094 loop {
3095 match body.frame().await {
3096 None => break,
3097 Some(Err(e)) => {
3098 let _ = tokio::fs::remove_file(dest_path).await;
3099 return Err((
3100 StatusCode::BAD_REQUEST,
3101 Json(serde_json::json!({"error": format!("Stream error: {e}")})),
3102 )
3103 .into_response());
3104 }
3105 Some(Ok(frame)) => {
3106 if let Ok(data) = frame.into_data() {
3107 written += data.len() as u64;
3108 if written > max_bytes {
3109 let _ = tokio::fs::remove_file(dest_path).await;
3110 return Err((
3111 StatusCode::PAYLOAD_TOO_LARGE,
3112 Json(serde_json::json!({"error": "Tarball exceeds the allowed size limit"})),
3113 )
3114 .into_response());
3115 }
3116 if let Err(e) = file.write_all(&data).await {
3117 let _ = tokio::fs::remove_file(dest_path).await;
3118 tracing::error!(event = "upload_io_error", "tarball write error: {e}");
3119 return Err((
3120 StatusCode::INTERNAL_SERVER_ERROR,
3121 Json(serde_json::json!({"error": "Upload write failed"})),
3122 )
3123 .into_response());
3124 }
3125 }
3126 }
3127 }
3128 }
3129 drop(file);
3130 Ok(written)
3131}
3132
3133#[allow(clippy::result_large_err)] async fn extract_tarball_to_staging(
3138 tarball_path: &Path,
3139 staging: &Path,
3140 max_decompressed_bytes: u64,
3141) -> Result<(), Response> {
3142 let staging_clone = staging.to_path_buf();
3143 let tarball_clone = tarball_path.to_path_buf();
3144 let extract_result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
3145 let file = std::fs::File::open(&tarball_clone)?;
3146 let gz = flate2::read::GzDecoder::new(std::io::BufReader::new(file));
3147 let limited = SizeLimitReader {
3148 inner: gz,
3149 remaining: max_decompressed_bytes,
3150 };
3151 let mut archive = tar::Archive::new(limited);
3152 archive.set_overwrite(true);
3153 archive.set_preserve_permissions(false);
3154 std::fs::create_dir_all(&staging_clone)?;
3155 archive.unpack(&staging_clone)?;
3156 Ok(())
3157 })
3158 .await;
3159 let _ = tokio::fs::remove_file(tarball_path).await;
3160
3161 match extract_result {
3162 Ok(Ok(())) => Ok(()),
3163 Ok(Err(e)) => {
3164 let _ = tokio::fs::remove_dir_all(staging).await;
3165 let is_size_limit = e.to_string().contains("decompressed size limit exceeded");
3166 tracing::warn!(
3167 event = "upload_extract_error",
3168 "tarball extraction failed: {e:#}"
3169 );
3170 let (status, msg) = if is_size_limit {
3171 (
3172 StatusCode::PAYLOAD_TOO_LARGE,
3173 "Archive exceeds the decompressed size limit",
3174 )
3175 } else {
3176 (StatusCode::BAD_REQUEST, "Failed to extract archive")
3177 };
3178 Err((status, Json(serde_json::json!({"error": msg}))).into_response())
3179 }
3180 Err(e) => {
3181 let _ = tokio::fs::remove_dir_all(staging).await;
3182 tracing::error!(
3183 event = "upload_extract_panic",
3184 "tarball extraction task panicked: {e}"
3185 );
3186 Err((
3187 StatusCode::INTERNAL_SERVER_ERROR,
3188 Json(serde_json::json!({"error": "Archive extraction failed"})),
3189 )
3190 .into_response())
3191 }
3192 }
3193}
3194
3195async fn find_single_top_dir(staging: &Path) -> Option<PathBuf> {
3199 let mut entries = tokio::fs::read_dir(staging).await.ok()?;
3200 let first = entries.next_entry().await.ok()??;
3201 if !first.path().is_dir() {
3202 return None;
3203 }
3204 if entries.next_entry().await.unwrap_or(None).is_some() {
3205 return None;
3206 }
3207 Some(first.path())
3208}
3209
3210#[derive(Deserialize)]
3217struct UploadDirRequest {
3218 files: Vec<UploadedFile>,
3219 upload_id: Option<String>,
3222}
3223
3224#[derive(Deserialize)]
3225struct UploadedFile {
3226 path: String,
3228 content: String,
3230}
3231
3232async fn upload_directory_handler(
3242 State(state): State<AppState>,
3243 Json(body): Json<UploadDirRequest>,
3244) -> Response {
3245 if !state.server_mode {
3246 return StatusCode::NOT_FOUND.into_response();
3247 }
3248 if let Err(resp) = validate_upload_dir_request(&body) {
3249 return resp;
3250 }
3251 let (upload_id, staging) = resolve_or_create_staging(body.upload_id.as_deref());
3254 match write_upload_files(&body.files, &staging, &upload_id).await {
3255 Ok((file_count, project_root)) => {
3256 let scan_root = project_root.unwrap_or_else(|| staging.clone());
3257 Json(serde_json::json!({
3258 "tmp_path": scan_root.to_string_lossy(),
3259 "file_count": file_count,
3260 "upload_id": upload_id.clone()
3261 }))
3262 .into_response()
3263 }
3264 Err(resp) => resp,
3265 }
3266}
3267
3268#[derive(Deserialize)]
3270struct UploadFileRequest {
3271 filename: String,
3273 content: String,
3275}
3276
3277async fn upload_file_handler(
3283 State(state): State<AppState>,
3284 Json(body): Json<UploadFileRequest>,
3285) -> Response {
3286 const MAX_FILE_BYTES: usize = 10 * 1024 * 1024; if !state.server_mode {
3289 return StatusCode::NOT_FOUND.into_response();
3290 }
3291
3292 let Ok(data) = base64::Engine::decode(
3293 &base64::engine::general_purpose::STANDARD,
3294 body.content.as_bytes(),
3295 ) else {
3296 return (
3297 StatusCode::BAD_REQUEST,
3298 Json(serde_json::json!({"error": "Invalid base64 content"})),
3299 )
3300 .into_response();
3301 };
3302
3303 if data.len() > MAX_FILE_BYTES {
3304 return (
3305 StatusCode::PAYLOAD_TOO_LARGE,
3306 Json(serde_json::json!({"error": "File exceeds the 10 MB limit"})),
3307 )
3308 .into_response();
3309 }
3310
3311 let filename = std::path::Path::new(&body.filename)
3313 .file_name()
3314 .map_or_else(|| "upload".to_owned(), |n| n.to_string_lossy().into_owned());
3315
3316 let upload_id = uuid::Uuid::new_v4();
3317 let staging = std::env::temp_dir()
3318 .join("oxide-sloc-uploads")
3319 .join(upload_id.to_string());
3320
3321 if tokio::fs::create_dir_all(&staging).await.is_err() {
3322 return (
3323 StatusCode::INTERNAL_SERVER_ERROR,
3324 Json(serde_json::json!({"error": "Failed to create staging directory"})),
3325 )
3326 .into_response();
3327 }
3328
3329 let dest = staging.join(&filename);
3330 if tokio::fs::write(&dest, &data).await.is_err() {
3331 let _ = tokio::fs::remove_dir_all(&staging).await;
3332 return (
3333 StatusCode::INTERNAL_SERVER_ERROR,
3334 Json(serde_json::json!({"error": "Failed to write uploaded file"})),
3335 )
3336 .into_response();
3337 }
3338
3339 Json(serde_json::json!({
3340 "tmp_path": dest.to_string_lossy(),
3341 "upload_id": upload_id.to_string()
3342 }))
3343 .into_response()
3344}
3345
3346struct SizeLimitReader<R> {
3362 inner: R,
3363 remaining: u64,
3364}
3365impl<R: std::io::Read> std::io::Read for SizeLimitReader<R> {
3366 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
3367 if self.remaining == 0 {
3368 return Err(std::io::Error::other("decompressed size limit exceeded"));
3369 }
3370 let n = self.inner.read(buf)?;
3371 self.remaining = self.remaining.saturating_sub(n as u64);
3372 Ok(n)
3373 }
3374}
3375
3376async fn upload_tarball_handler(
3377 State(state): State<AppState>,
3378 request: axum::extract::Request,
3379) -> Response {
3380 if !state.server_mode {
3381 return StatusCode::NOT_FOUND.into_response();
3382 }
3383
3384 let upload_id = uuid::Uuid::new_v4().to_string();
3385 let upload_base = upload_base_dir();
3386 let tarball_path = upload_base.join(format!("{upload_id}.tar.gz"));
3387 let staging = upload_staging_path(&upload_id);
3388 let (max_compressed_bytes, max_decompressed_bytes) = parse_tarball_size_caps();
3389
3390 if let Err(e) = tokio::fs::create_dir_all(&upload_base).await {
3391 tracing::error!(
3392 event = "upload_io_error",
3393 "failed to create upload base dir: {e}"
3394 );
3395 return (
3396 StatusCode::INTERNAL_SERVER_ERROR,
3397 Json(serde_json::json!({"error": "Upload initialization failed"})),
3398 )
3399 .into_response();
3400 }
3401
3402 let compressed_bytes =
3404 match stream_body_to_file(request.into_body(), &tarball_path, max_compressed_bytes).await {
3405 Ok(n) => n,
3406 Err(resp) => return resp,
3407 };
3408
3409 if let Err(resp) =
3411 extract_tarball_to_staging(&tarball_path, &staging, max_decompressed_bytes).await
3412 {
3413 return resp;
3414 }
3415
3416 let scan_root = find_single_top_dir(&staging)
3421 .await
3422 .unwrap_or_else(|| staging.clone());
3423
3424 let original_bytes = tokio::task::spawn_blocking({
3426 let p = scan_root.clone();
3427 move || dir_size_bytes(&p)
3428 })
3429 .await
3430 .unwrap_or(0);
3431
3432 Json(serde_json::json!({
3433 "tmp_path": scan_root.to_string_lossy(),
3434 "upload_id": upload_id,
3435 "compressed_bytes": compressed_bytes,
3436 "original_bytes": original_bytes,
3437 }))
3438 .into_response()
3439}
3440
3441#[derive(Deserialize)]
3442struct LocateReportForm {
3443 file_path: String,
3444 #[serde(default)]
3445 redirect_url: Option<String>,
3446 #[serde(default)]
3447 expected_run_id: Option<String>,
3448}
3449
3450fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
3452 let html = ErrorTemplate {
3453 message: message.into(),
3454 last_report_url: Some("/view-reports".to_string()),
3455 last_report_label: Some("View Reports".to_string()),
3456 run_id: None,
3457 error_code: None,
3458 csp_nonce: csp_nonce.to_owned(),
3459 version: env!("CARGO_PKG_VERSION"),
3460 }
3461 .render()
3462 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3463 Html(html).into_response()
3464}
3465
3466fn registry_entry_from_run(
3468 run: &AnalysisRun,
3469 json_path: PathBuf,
3470 html_path: PathBuf,
3471) -> RegistryEntry {
3472 let project_label = run.input_roots.first().map_or_else(
3473 || "Unknown Project".to_string(),
3474 |r| sanitize_project_label(r),
3475 );
3476 RegistryEntry {
3477 run_id: run.tool.run_id.clone(),
3478 timestamp_utc: run.tool.timestamp_utc,
3479 project_label,
3480 input_roots: run.input_roots.clone(),
3481 json_path: Some(json_path),
3482 html_path: Some(html_path),
3483 pdf_path: None,
3484 summary: ScanSummarySnapshot::from(&run.summary_totals),
3485 csv_path: None,
3486 xlsx_path: None,
3487 git_branch: None,
3488 git_commit: None,
3489 git_commit_long: None,
3490 git_author: None,
3491 git_tags: None,
3492 git_nearest_tag: None,
3493 git_commit_date: None,
3494 }
3495}
3496
3497pub(crate) async fn register_artifacts_in_registry(
3500 state: &AppState,
3501 label: &str,
3502 run: &AnalysisRun,
3503 artifacts: &RunArtifacts,
3504) {
3505 let Some(json_path) = artifacts.json_path.clone() else {
3506 return;
3507 };
3508 let Some(html_path) = artifacts.html_path.clone() else {
3509 return;
3510 };
3511 let mut entry = registry_entry_from_run(run, json_path, html_path);
3512 entry.project_label = label.to_owned();
3513 let mut reg = state.registry.lock().await;
3514 reg.add_entry(entry);
3515 let _ = reg.save(&state.registry_path);
3516}
3517
3518fn is_html_report_file(p: &Path) -> bool {
3519 p.is_file()
3520 && p.extension()
3521 .and_then(|x| x.to_str())
3522 .is_some_and(|x| x.eq_ignore_ascii_case("html"))
3523 && p.file_name()
3524 .and_then(|n| n.to_str())
3525 .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
3526}
3527
3528fn find_html_report_in_dir(dir: &Path) -> Option<PathBuf> {
3529 fs::read_dir(dir)
3530 .ok()?
3531 .flatten()
3532 .map(|e| e.path())
3533 .find(|p| is_html_report_file(p))
3534}
3535
3536fn find_html_report_in_tree(dir: &Path) -> Option<PathBuf> {
3537 if let Some(f) = find_html_report_in_dir(dir) {
3538 return Some(f);
3539 }
3540 if let Ok(rd) = fs::read_dir(dir) {
3541 for entry in rd.flatten() {
3542 let sub = entry.path();
3543 if sub.is_dir()
3544 && let Some(f) = find_html_report_in_dir(&sub)
3545 {
3546 return Some(f);
3547 }
3548 }
3549 }
3550 None
3551}
3552
3553#[allow(clippy::result_large_err)]
3558fn validate_locate_request(
3559 state: &AppState,
3560 file_path: &str,
3561 csp_nonce: &str,
3562) -> Result<(PathBuf, PathBuf), Response> {
3563 let raw = PathBuf::from(file_path);
3564
3565 let html_path = if raw.is_dir() {
3567 let found = find_html_report_in_tree(&raw);
3568 match found {
3569 Some(f) => strip_unc_prefix(fs::canonicalize(&f).unwrap_or(f)),
3570 None => {
3571 return Err(locate_report_error(
3572 "No HTML report file found in the selected folder.\n\nMake sure you selected \
3573 the folder that contains your scan output (result_*.html or report_*.html).",
3574 csp_nonce,
3575 ));
3576 }
3577 }
3578 } else {
3579 let file_ext = raw
3580 .extension()
3581 .and_then(|e| e.to_str())
3582 .unwrap_or("")
3583 .to_ascii_lowercase();
3584 if file_ext != "html" {
3585 return Err(locate_report_error(
3586 "Please select the scan output folder, or an .html report file directly.",
3587 csp_nonce,
3588 ));
3589 }
3590 match fs::canonicalize(&raw) {
3591 Ok(p) => strip_unc_prefix(p),
3592 Err(_) => {
3593 return Err(locate_report_error(
3594 "Report file not found or path is invalid.",
3595 csp_nonce,
3596 ));
3597 }
3598 }
3599 };
3600
3601 if state.server_mode {
3602 let output_root = resolve_output_root(None);
3603 let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
3604 if !html_path.starts_with(&canonical_root) {
3605 return Err(locate_report_error(
3606 "Report file must be within the configured output directory.",
3607 csp_nonce,
3608 ));
3609 }
3610 }
3611 let parent = match html_path.parent() {
3612 Some(p) => p.to_path_buf(),
3613 None => {
3614 return Err(locate_report_error(
3615 "Report file has no parent directory.",
3616 csp_nonce,
3617 ));
3618 }
3619 };
3620 Ok((html_path, parent))
3621}
3622
3623fn locate_handler_err(want_json: bool, msg: String, csp_nonce: &str) -> Response {
3625 if want_json {
3626 (
3627 StatusCode::UNPROCESSABLE_ENTITY,
3628 axum::Json(serde_json::json!({"ok": false, "message": msg})),
3629 )
3630 .into_response()
3631 } else {
3632 locate_report_error(msg, csp_nonce)
3633 }
3634}
3635
3636fn redirect_or_json_ok(want_json: bool, redirect: &str) -> Response {
3638 if want_json {
3639 axum::Json(serde_json::json!({"ok": true, "redirect": redirect})).into_response()
3640 } else {
3641 axum::response::Redirect::to(redirect).into_response()
3642 }
3643}
3644
3645fn find_json_run_by_id(candidates: &[PathBuf], expected: &str) -> Option<(PathBuf, String)> {
3648 for jpath in candidates {
3649 if let Ok(run) = read_json(jpath)
3650 && (expected.is_empty() || run.tool.run_id == expected)
3651 {
3652 return Some((jpath.clone(), run.tool.run_id));
3653 }
3654 }
3655 None
3656}
3657
3658fn resolve_scan_root(html_path: &Path, parent: &Path) -> PathBuf {
3659 html_path
3660 .parent()
3661 .and_then(|p| p.parent())
3662 .map_or_else(|| parent.to_path_buf(), std::path::Path::to_path_buf)
3663}
3664
3665fn gather_json_candidates(scan_root: &Path, parent: &Path) -> Vec<PathBuf> {
3666 let mut hits = collect_result_json_candidates(scan_root);
3667 if hits.is_empty() {
3668 hits = collect_result_json_candidates(parent);
3669 }
3670 hits.sort();
3671 hits
3672}
3673
3674#[allow(clippy::too_many_lines)]
3675async fn locate_report_handler(
3676 State(state): State<AppState>,
3677 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3678 headers: axum::http::HeaderMap,
3679 Form(form): Form<LocateReportForm>,
3680) -> impl IntoResponse {
3681 let want_json = headers
3682 .get(axum::http::header::ACCEPT)
3683 .and_then(|v| v.to_str().ok())
3684 .is_some_and(|v| v.contains("application/json"));
3685
3686 let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
3687 Ok(v) => v,
3688 Err(resp) => {
3689 if want_json {
3690 return locate_handler_err(
3691 true,
3692 "No HTML report file found in the selected folder. \
3693 Make sure you selected the folder that contains your \
3694 scan output (look for the folder with html/, json/, pdf/ subdirs)."
3695 .to_string(),
3696 &csp_nonce,
3697 );
3698 }
3699 return resp;
3700 }
3701 };
3702
3703 let scan_root_owned = resolve_scan_root(&html_path, &parent);
3706 let scan_root: &Path = &scan_root_owned;
3707 let json_candidates = gather_json_candidates(scan_root, &parent);
3708
3709 let expected_run_id = form
3711 .expected_run_id
3712 .as_deref()
3713 .unwrap_or("")
3714 .trim()
3715 .to_string();
3716
3717 let matched_json = find_json_run_by_id(&json_candidates, &expected_run_id);
3718
3719 if matched_json.is_none() && !json_candidates.is_empty() && !expected_run_id.is_empty() {
3721 let actual = json_candidates
3722 .iter()
3723 .find_map(|p| read_json(p).ok().map(|r| r.tool.run_id))
3724 .unwrap_or_else(|| "unknown".to_string());
3725 return locate_handler_err(
3726 want_json,
3727 format!(
3728 "This folder contains a different scan.\n\n\
3729 Expected run ID : {expected_run_id}\n\
3730 Found run ID : {actual}\n\n\
3731 Please select the folder that contains the correct scan output."
3732 ),
3733 &csp_nonce,
3734 );
3735 }
3736
3737 let safe_redirect = form
3738 .redirect_url
3739 .as_deref()
3740 .filter(|u| u.starts_with('/') && !u.starts_with("//"))
3741 .unwrap_or("/view-reports?linked=1")
3742 .to_string();
3743
3744 let mut reg = state.registry.lock().await;
3745
3746 if let Some((json_path, run_id)) = matched_json {
3747 if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
3749 entry.html_path = Some(html_path);
3750 entry.json_path = Some(json_path);
3751 let _ = reg.save(&state.registry_path);
3752 drop(reg);
3753 state.artifacts.lock().await.remove(&run_id);
3755 return redirect_or_json_ok(want_json, &safe_redirect);
3756 }
3757 match read_json(&json_path) {
3759 Ok(run) => {
3760 let entry = registry_entry_from_run(&run, json_path, html_path);
3761 reg.add_entry(entry);
3762 let _ = reg.save(&state.registry_path);
3763 drop(reg);
3764 state.artifacts.lock().await.remove(&run_id);
3765 return redirect_or_json_ok(want_json, &safe_redirect);
3766 }
3767 Err(e) => {
3768 drop(reg);
3769 return locate_handler_err(
3770 want_json,
3771 format!(
3772 "Found the scan folder but could not parse the result JSON.\n\n\
3773 The file may have been saved by an older version of OxideSLOC. \
3774 Re-running the analysis will create a fresh, compatible record.\n\n\
3775 Error: {e}"
3776 ),
3777 &csp_nonce,
3778 );
3779 }
3780 }
3781 }
3782
3783 if let Some(entry) = reg
3785 .entries
3786 .iter_mut()
3787 .find(|e| !expected_run_id.is_empty() && e.run_id == expected_run_id)
3788 {
3789 entry.html_path = Some(html_path.clone());
3790 let _ = reg.save(&state.registry_path);
3791 drop(reg);
3792 state.artifacts.lock().await.remove(&expected_run_id);
3793 return redirect_or_json_ok(want_json, &safe_redirect);
3794 }
3795
3796 drop(reg);
3797 let hint = if state.server_mode {
3798 String::new()
3799 } else {
3800 format!(
3801 "\n\nSearched folder : {}\nHTML found : {}",
3802 scan_root.display(),
3803 html_path.display()
3804 )
3805 };
3806 locate_handler_err(
3807 want_json,
3808 format!(
3809 "Could not link this report.\n\n\
3810 No result_*.json was found in the selected folder. \
3811 Make sure you selected the top-level scan output folder \
3812 (the one that contains html/, json/, pdf/ subfolders).{hint}"
3813 ),
3814 &csp_nonce,
3815 )
3816}
3817
3818fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
3820 fs::read_dir(dir)
3821 .ok()?
3822 .flatten()
3823 .map(|e| e.path())
3824 .find(|p| {
3825 p.is_file()
3826 && p.file_stem()
3827 .and_then(|n| n.to_str())
3828 .is_some_and(|n| n.starts_with("result"))
3829 && p.extension()
3830 .is_some_and(|e| e.eq_ignore_ascii_case("json"))
3831 })
3832}
3833
3834#[derive(Deserialize)]
3835struct LocateReportsDirForm {
3836 folder_path: String,
3837}
3838
3839#[allow(clippy::too_many_lines)] async fn locate_reports_dir_handler(
3841 State(state): State<AppState>,
3842 Form(form): Form<LocateReportsDirForm>,
3843) -> impl IntoResponse {
3844 if state.server_mode {
3845 return StatusCode::NOT_FOUND.into_response();
3846 }
3847 let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
3848 Ok(p) => strip_unc_prefix(p),
3849 Err(_) => {
3850 return axum::response::Redirect::to(
3851 "/view-reports?error=Folder+not+found+or+path+is+invalid.",
3852 )
3853 .into_response();
3854 }
3855 };
3856 if !folder.is_dir() {
3857 return axum::response::Redirect::to(
3858 "/view-reports?error=Selected+path+is+not+a+directory.",
3859 )
3860 .into_response();
3861 }
3862
3863 let candidates = collect_result_json_candidates(&folder);
3864
3865 if candidates.is_empty() {
3866 return axum::response::Redirect::to(
3867 "/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
3868 )
3869 .into_response();
3870 }
3871
3872 let mut linked_count: usize = 0;
3873 let mut reg = state.registry.lock().await;
3874 for json_path in candidates {
3875 let Some(parent) = json_path.parent().map(PathBuf::from) else {
3876 continue;
3877 };
3878 if is_dir_already_registered(®, &parent) {
3879 continue;
3880 }
3881 let Some(entry) = build_registry_entry_from_json(json_path) else {
3882 continue;
3883 };
3884 reg.add_entry(entry);
3885 linked_count += 1;
3886 }
3887 let _ = reg.save(&state.registry_path);
3888 drop(reg);
3889
3890 if linked_count == 0 {
3891 return axum::response::Redirect::to(
3892 "/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
3893 )
3894 .into_response();
3895 }
3896 axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
3897}
3898
3899#[derive(Deserialize)]
3900struct RelocateScanForm {
3901 run_id: String,
3902 folder_path: String,
3903 redirect_url: String,
3904}
3905
3906fn relocate_folder_err(
3909 want_json: bool,
3910 status: StatusCode,
3911 msg: &str,
3912 run_id: &str,
3913 folder_hint: &str,
3914 redirect_url: &str,
3915 csp_nonce: &str,
3916) -> Response {
3917 if want_json {
3918 (
3919 status,
3920 axum::Json(serde_json::json!({"ok": false, "message": msg})),
3921 )
3922 .into_response()
3923 } else {
3924 missing_scan_relocate_response(msg, run_id, folder_hint, redirect_url, false, csp_nonce)
3925 }
3926}
3927
3928#[allow(clippy::too_many_lines)]
3929async fn relocate_scan_handler(
3930 State(state): State<AppState>,
3931 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
3932 headers: axum::http::HeaderMap,
3933 Form(form): Form<RelocateScanForm>,
3934) -> impl IntoResponse {
3935 let want_json = headers
3936 .get(axum::http::header::ACCEPT)
3937 .and_then(|v| v.to_str().ok())
3938 .is_some_and(|v| v.contains("application/json"));
3939 if state.server_mode {
3940 return StatusCode::NOT_FOUND.into_response();
3941 }
3942
3943 let run_id = form.run_id.trim().to_string();
3944 let redirect_url = form.redirect_url.trim().to_string();
3945
3946 let run_exists = {
3947 let reg = state.registry.lock().await;
3948 reg.find_by_run_id(&run_id).is_some()
3949 };
3950 if !run_exists {
3951 if want_json {
3952 return (
3953 StatusCode::NOT_FOUND,
3954 axum::Json(serde_json::json!({
3955 "ok": false,
3956 "message": format!("Run ID '{run_id}' not found in registry.")
3957 })),
3958 )
3959 .into_response();
3960 }
3961 let html = ErrorTemplate {
3962 message: format!("Run ID '{run_id}' not found in registry."),
3963 last_report_url: Some("/compare-scans".to_string()),
3964 last_report_label: Some("Compare Scans".to_string()),
3965 run_id: Some(run_id.clone()),
3966 error_code: Some(404),
3967 csp_nonce: csp_nonce.clone(),
3968 version: env!("CARGO_PKG_VERSION"),
3969 }
3970 .render()
3971 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
3972 return Html(html).into_response();
3973 }
3974
3975 let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
3976 Ok(p) => strip_unc_prefix(p),
3977 Err(_) => {
3978 return relocate_folder_err(
3979 want_json,
3980 StatusCode::UNPROCESSABLE_ENTITY,
3981 "Folder not found or path is invalid.",
3982 &run_id,
3983 form.folder_path.trim(),
3984 &redirect_url,
3985 &csp_nonce,
3986 );
3987 }
3988 };
3989 if !folder.is_dir() {
3990 return relocate_folder_err(
3991 want_json,
3992 StatusCode::UNPROCESSABLE_ENTITY,
3993 "Selected path is not a directory.",
3994 &run_id,
3995 &folder.display().to_string(),
3996 &redirect_url,
3997 &csp_nonce,
3998 );
3999 }
4000
4001 let json_candidates = find_result_files_by_ext(&folder, "json");
4002 if json_candidates.is_empty() {
4003 let msg = format!(
4004 "No result JSON files found in the selected folder.\nSearched: {}",
4005 folder.display()
4006 );
4007 return relocate_folder_err(
4008 want_json,
4009 StatusCode::UNPROCESSABLE_ENTITY,
4010 &msg,
4011 &run_id,
4012 &folder.display().to_string(),
4013 &redirect_url,
4014 &csp_nonce,
4015 );
4016 }
4017
4018 let Some(json_path) = find_matching_run_json(&json_candidates, &run_id) else {
4019 let msg = format!(
4020 "No matching scan found in the selected folder.\n\
4021 The JSON files present do not contain run ID: {run_id}\n\
4022 Searched: {}",
4023 folder.display()
4024 );
4025 return relocate_folder_err(
4026 want_json,
4027 StatusCode::UNPROCESSABLE_ENTITY,
4028 &msg,
4029 &run_id,
4030 &folder.display().to_string(),
4031 &redirect_url,
4032 &csp_nonce,
4033 );
4034 };
4035
4036 let html_path = find_result_files_by_ext(&folder, "html").into_iter().next();
4037 let pdf_path = find_result_files_by_ext(&folder, "pdf").into_iter().next();
4038 update_run_file_paths(&state, &run_id, json_path, html_path, pdf_path).await;
4039
4040 let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
4041 redirect_url
4042 } else {
4043 "/compare-scans".to_string()
4044 };
4045 redirect_or_json_ok(want_json, &safe_redirect)
4046}
4047
4048fn find_result_files_by_ext(folder: &std::path::Path, ext: &str) -> Vec<PathBuf> {
4049 let mut out = Vec::new();
4050 collect_scan_files_by_ext(folder, ext, &mut out);
4051 if let Ok(rd) = fs::read_dir(folder) {
4052 for entry in rd.flatten() {
4053 let sub = entry.path();
4054 if sub.is_dir() {
4055 collect_scan_files_by_ext(&sub, ext, &mut out);
4056 }
4057 }
4058 }
4059 out
4060}
4061
4062fn collect_scan_files_by_ext(dir: &std::path::Path, ext: &str, out: &mut Vec<PathBuf>) {
4063 let Ok(rd) = fs::read_dir(dir) else { return };
4064 for entry in rd.flatten() {
4065 let p = entry.path();
4066 if p.is_file()
4067 && p.file_stem()
4068 .and_then(|n| n.to_str())
4069 .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
4070 && p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext))
4071 {
4072 out.push(p);
4073 }
4074 }
4075}
4076
4077fn find_matching_run_json(candidates: &[PathBuf], run_id: &str) -> Option<PathBuf> {
4078 candidates
4079 .iter()
4080 .find(|c| read_json(c).ok().is_some_and(|r| r.tool.run_id == run_id))
4081 .cloned()
4082}
4083
4084fn output_folder_hint(json_path: &std::path::Path) -> String {
4089 let Some(direct_parent) = json_path.parent() else {
4090 return String::new();
4091 };
4092 let parent_name = direct_parent
4093 .file_name()
4094 .and_then(|n| n.to_str())
4095 .unwrap_or("");
4096 if matches!(parent_name, "json" | "html" | "pdf" | "excel") {
4097 direct_parent.parent().map_or_else(
4098 || direct_parent.display().to_string(),
4099 |p| p.display().to_string(),
4100 )
4101 } else {
4102 direct_parent.display().to_string()
4103 }
4104}
4105
4106async fn update_run_file_paths(
4107 state: &AppState,
4108 run_id: &str,
4109 json_path: PathBuf,
4110 html_path: Option<PathBuf>,
4111 pdf_path: Option<PathBuf>,
4112) {
4113 {
4114 let mut reg = state.registry.lock().await;
4115 if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
4116 entry.json_path = Some(json_path.clone());
4117 if let Some(ref hp) = html_path {
4118 entry.html_path = Some(hp.clone());
4119 }
4120 if let Some(ref pp) = pdf_path {
4121 entry.pdf_path = Some(pp.clone());
4122 }
4123 }
4124 let _ = reg.save(&state.registry_path);
4125 }
4126 {
4129 let mut map = state.artifacts.lock().await;
4130 if let Some(arts) = map.get_mut(run_id) {
4131 arts.json_path = Some(json_path);
4132 if let Some(hp) = html_path {
4133 arts.html_path = Some(hp);
4134 }
4135 if let Some(pp) = pdf_path {
4136 arts.pdf_path = Some(pp);
4137 }
4138 }
4139 }
4140}
4141
4142fn missing_scan_relocate_response(
4143 message: &str,
4144 run_id: &str,
4145 folder_hint: &str,
4146 redirect_url: &str,
4147 server_mode: bool,
4148 csp_nonce: &str,
4149) -> axum::response::Response {
4150 let html = RelocateScanTemplate {
4151 message: message.to_string(),
4152 run_id: run_id.to_string(),
4153 folder_hint: folder_hint.to_string(),
4154 redirect_url: redirect_url.to_string(),
4155 server_mode,
4156 csp_nonce: csp_nonce.to_owned(),
4157 version: env!("CARGO_PKG_VERSION"),
4158 }
4159 .render()
4160 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
4161 (StatusCode::NOT_FOUND, Html(html)).into_response()
4162}
4163
4164fn find_file_by_ext(dir: &Path, ext: &str) -> Option<PathBuf> {
4168 fs::read_dir(dir)
4169 .ok()?
4170 .flatten()
4171 .map(|e| e.path())
4172 .find(|p| {
4173 p.is_file()
4174 && p.extension()
4175 .and_then(|e| e.to_str())
4176 .is_some_and(|e| e.eq_ignore_ascii_case(ext))
4177 })
4178}
4179
4180fn subdir_result_json_candidates(sub: &std::path::Path) -> Vec<PathBuf> {
4184 let mut out = Vec::new();
4185 if let Some(j) = find_result_json_in_dir(sub) {
4186 out.push(j);
4187 }
4188 let json_sub = sub.join("json");
4189 if json_sub.is_dir()
4190 && let Some(j) = find_result_json_in_dir(&json_sub)
4191 {
4192 out.push(j);
4193 }
4194 out
4195}
4196
4197fn collect_result_json_candidates(folder: &std::path::Path) -> Vec<PathBuf> {
4198 let mut candidates = Vec::new();
4199 if let Some(j) = find_result_json_in_dir(folder) {
4200 candidates.push(j);
4201 }
4202 let Ok(dir_entries) = fs::read_dir(folder) else {
4203 return candidates;
4204 };
4205 for entry in dir_entries.flatten() {
4206 let sub = entry.path();
4207 if sub.is_dir() {
4208 candidates.extend(subdir_result_json_candidates(&sub));
4209 }
4210 }
4211 candidates
4212}
4213
4214fn is_dir_already_registered(reg: &ScanRegistry, parent: &std::path::Path) -> bool {
4215 reg.entries.iter().any(|e| {
4216 let dir_match = e
4217 .json_path
4218 .as_ref()
4219 .and_then(|p| p.parent())
4220 .is_some_and(|p| p == parent)
4221 || e.html_path
4222 .as_ref()
4223 .and_then(|p| p.parent())
4224 .is_some_and(|p| p == parent);
4225 dir_match
4226 && (e.json_path.as_ref().is_some_and(|p| p.exists())
4227 || e.html_path.as_ref().is_some_and(|p| p.exists()))
4228 })
4229}
4230
4231fn build_registry_entry_from_json(json_path: PathBuf) -> Option<RegistryEntry> {
4232 let json_dir = json_path.parent()?.to_path_buf();
4233 let (html_path, pdf_path, csv_path, xlsx_path) =
4236 if json_dir.file_name().and_then(|n| n.to_str()) == Some("json") {
4237 let scan_root = json_dir.parent()?;
4238 let html = find_html_report_in_dir(&scan_root.join("html"))
4239 .or_else(|| find_html_report_in_dir(scan_root));
4240 let pdf = find_file_by_ext(&scan_root.join("pdf"), "pdf");
4241 let csv = find_file_by_ext(&scan_root.join("excel"), "csv");
4242 let xlsx = find_file_by_ext(&scan_root.join("excel"), "xlsx");
4243 (html, pdf, csv, xlsx)
4244 } else {
4245 let html = fs::read_dir(&json_dir).ok().and_then(|rd| {
4246 rd.flatten()
4247 .map(|e| e.path())
4248 .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
4249 });
4250 (html, None, None, None)
4251 };
4252 let run = read_json(&json_path).ok()?;
4253 let project_label = run.input_roots.first().map_or_else(
4254 || "Unknown Project".to_string(),
4255 |r| sanitize_project_label(r),
4256 );
4257 Some(RegistryEntry {
4258 run_id: run.tool.run_id.clone(),
4259 timestamp_utc: run.tool.timestamp_utc,
4260 project_label,
4261 input_roots: run.input_roots.clone(),
4262 json_path: Some(json_path),
4263 html_path,
4264 pdf_path,
4265 csv_path,
4266 xlsx_path,
4267 summary: ScanSummarySnapshot::from(&run.summary_totals),
4268 git_branch: run.git_branch.clone(),
4269 git_commit: run.git_commit_short.clone(),
4270 git_commit_long: run.git_commit_long.clone(),
4271 git_author: run.git_commit_author.clone(),
4272 git_tags: run.git_tags.clone(),
4273 git_nearest_tag: run.git_nearest_tag.clone(),
4274 git_commit_date: run.git_commit_date,
4275 })
4276}
4277
4278fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
4281 let mut linked = 0usize;
4282 for json_path in collect_result_json_candidates(folder) {
4283 let Some(parent) = json_path.parent().map(PathBuf::from) else {
4284 continue;
4285 };
4286 if is_dir_already_registered(reg, &parent) {
4287 continue;
4288 }
4289 let Some(entry) = build_registry_entry_from_json(json_path) else {
4290 continue;
4291 };
4292 reg.add_entry(entry);
4293 linked += 1;
4294 }
4295 linked
4296}
4297
4298async fn auto_scan_watched_dirs(state: &AppState) {
4300 let dirs: Vec<PathBuf> = {
4301 let wd = state.watched_dirs.lock().await;
4302 wd.dirs.clone()
4303 };
4304 {
4308 let output_root = resolve_output_root(None);
4309 let mut roots: Vec<PathBuf> = dirs.clone();
4310 if let Ok(canon) = fs::canonicalize(&output_root) {
4311 roots.push(strip_unc_prefix(canon));
4312 }
4313 roots.push(output_root);
4314 let mut reg = state.registry.lock().await;
4315 if reg.retain_under_roots(&roots) > 0 {
4316 let _ = reg.save(&state.registry_path);
4317 }
4318 }
4319 if dirs.is_empty() {
4320 return;
4321 }
4322 let mut reg = state.registry.lock().await;
4323 let mut total = 0usize;
4324 for dir in &dirs {
4325 if dir.is_dir() {
4326 total += scan_folder_into_registry(dir, &mut reg);
4327 }
4328 }
4329 if total > 0 {
4330 let _ = reg.save(&state.registry_path);
4331 }
4332}
4333
4334#[derive(Deserialize)]
4337struct WatchedDirForm {
4338 folder_path: String,
4339 #[serde(default = "default_redirect")]
4340 redirect_to: String,
4341}
4342
4343fn default_redirect() -> String {
4344 "/view-reports".to_string()
4345}
4346
4347#[derive(Deserialize)]
4348struct WatchedDirRefreshForm {
4349 #[serde(default = "default_redirect")]
4350 redirect_to: String,
4351}
4352
4353fn safe_redirect(dest: &str) -> &str {
4357 if dest.starts_with('/') { dest } else { "/" }
4358}
4359
4360async fn add_watched_dir_handler(
4363 State(state): State<AppState>,
4364 Form(form): Form<WatchedDirForm>,
4365) -> impl IntoResponse {
4366 if state.server_mode {
4367 return StatusCode::NOT_FOUND.into_response();
4368 }
4369 let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
4370 strip_unc_prefix(p)
4371 } else {
4372 let dest = format!(
4373 "{}?error=Folder+not+found+or+path+is+invalid.",
4374 safe_redirect(&form.redirect_to)
4375 );
4376 return axum::response::Redirect::to(&dest).into_response();
4377 };
4378 if !folder.is_dir() {
4379 let dest = format!(
4380 "{}?error=Selected+path+is+not+a+directory.",
4381 safe_redirect(&form.redirect_to)
4382 );
4383 return axum::response::Redirect::to(&dest).into_response();
4384 }
4385
4386 {
4388 let mut wd = state.watched_dirs.lock().await;
4389 wd.add(folder.clone());
4390 let _ = wd.save(&state.watched_dirs_path);
4391 }
4392
4393 let linked = {
4395 let mut reg = state.registry.lock().await;
4396 let n = scan_folder_into_registry(&folder, &mut reg);
4397 if n > 0 {
4398 let _ = reg.save(&state.registry_path);
4399 }
4400 n
4401 };
4402
4403 let dest = if linked > 0 {
4404 format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
4405 } else {
4406 format!(
4407 "{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
4408 safe_redirect(&form.redirect_to)
4409 )
4410 };
4411 axum::response::Redirect::to(&dest).into_response()
4412}
4413
4414async fn remove_watched_dir_handler(
4415 State(state): State<AppState>,
4416 Form(form): Form<WatchedDirForm>,
4417) -> impl IntoResponse {
4418 if state.server_mode {
4419 return StatusCode::NOT_FOUND.into_response();
4420 }
4421 let folder = PathBuf::from(&form.folder_path);
4422 {
4423 let mut wd = state.watched_dirs.lock().await;
4424 wd.remove(&folder);
4425 let _ = wd.save(&state.watched_dirs_path);
4426 }
4427 {
4429 let mut reg = state.registry.lock().await;
4430 if reg.remove_entries_under(&folder) > 0 {
4431 let _ = reg.save(&state.registry_path);
4432 }
4433 }
4434 axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
4435}
4436
4437async fn refresh_watched_dirs_handler(
4438 State(state): State<AppState>,
4439 Form(form): Form<WatchedDirRefreshForm>,
4440) -> impl IntoResponse {
4441 if state.server_mode {
4442 return StatusCode::NOT_FOUND.into_response();
4443 }
4444 let dirs: Vec<PathBuf> = {
4445 let wd = state.watched_dirs.lock().await;
4446 wd.dirs.clone()
4447 };
4448 let mut total = 0usize;
4449 {
4450 let mut reg = state.registry.lock().await;
4451 reg.prune_stale();
4452 for dir in &dirs {
4453 if dir.is_dir() {
4454 total += scan_folder_into_registry(dir, &mut reg);
4455 }
4456 }
4457 let _ = reg.save(&state.registry_path);
4458 }
4459 let dest = if total > 0 {
4460 format!("{}?linked={total}", safe_redirect(&form.redirect_to))
4461 } else {
4462 safe_redirect(&form.redirect_to).to_owned()
4463 };
4464 axum::response::Redirect::to(&dest).into_response()
4465}
4466
4467#[derive(Debug, Deserialize)]
4468struct OpenPathQuery {
4469 path: Option<String>,
4470}
4471
4472fn find_existing_ancestor(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
4473 let mut ancestor = std::path::Path::new(raw);
4474 loop {
4475 match ancestor.parent() {
4476 Some(p) => {
4477 ancestor = p;
4478 if ancestor.is_dir() {
4479 break;
4480 }
4481 }
4482 None => return Err((StatusCode::BAD_REQUEST, "no existing ancestor found")),
4483 }
4484 }
4485 Ok(ancestor.to_path_buf())
4486}
4487
4488async fn resolve_open_target(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
4489 match tokio::fs::canonicalize(raw).await {
4490 Ok(canonical) if canonical.is_file() => canonical
4491 .parent()
4492 .map_or(Err((StatusCode::BAD_REQUEST, "path has no parent")), |p| {
4493 Ok(p.to_path_buf())
4494 }),
4495 Ok(canonical) if canonical.is_dir() => Ok(canonical),
4496 Ok(_) => Err((StatusCode::BAD_REQUEST, "path is not a file or directory")),
4497 Err(_) => find_existing_ancestor(raw),
4498 }
4499}
4500
4501async fn open_path_handler(
4502 State(state): State<AppState>,
4503 Query(query): Query<OpenPathQuery>,
4504) -> impl IntoResponse {
4505 if state.server_mode {
4506 return Json(serde_json::json!({
4507 "server_mode_disabled": true,
4508 "message": "Opening a path in the file manager is only available in local desktop mode."
4509 }))
4510 .into_response();
4511 }
4512 if std::env::var("SLOC_HEADLESS").is_ok() {
4514 return Json(serde_json::json!({ "opened": false, "headless": true })).into_response();
4515 }
4516 let raw = match query.path.as_deref() {
4517 Some(p) if !p.is_empty() => p,
4518 _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
4519 };
4520
4521 let target = match resolve_open_target(raw).await {
4525 Ok(p) => p,
4526 Err((code, msg)) => return (code, msg).into_response(),
4527 };
4528
4529 #[cfg(target_os = "windows")]
4530 win_dialog_focus::open_folder_foreground(target);
4531 #[cfg(target_os = "macos")]
4532 let _ = std::process::Command::new("open")
4533 .arg(&target)
4534 .stdout(Stdio::null())
4535 .stderr(Stdio::null())
4536 .spawn();
4537 #[cfg(target_os = "linux")]
4538 {
4539 let folder_name = target
4540 .file_name()
4541 .and_then(|n| n.to_str())
4542 .map(str::to_owned);
4543 let _ = std::process::Command::new("xdg-open")
4544 .arg(&target)
4545 .stdout(Stdio::null())
4546 .stderr(Stdio::null())
4547 .spawn();
4548 if let Some(name) = folder_name {
4552 std::thread::spawn(move || {
4553 std::thread::sleep(std::time::Duration::from_millis(800));
4554 let _ = std::process::Command::new("wmctrl")
4555 .args(["-a", &name])
4556 .stdout(Stdio::null())
4557 .stderr(Stdio::null())
4558 .spawn();
4559 });
4560 }
4561 }
4562
4563 Json(serde_json::json!({"ok": true})).into_response()
4564}
4565
4566async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
4567 let (content_type, bytes): (&'static str, &'static [u8]) =
4568 match (folder.as_str(), file.as_str()) {
4569 ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
4570 ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
4571 ("icons", "c.png") => ("image/png", IMG_ICON_C),
4572 ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
4573 ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
4574 ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
4575 ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
4576 ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
4577 ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
4578 ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
4579 ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
4580 ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
4581 ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
4582 ("icons", "go.png") => ("image/png", IMG_ICON_GO),
4583 ("icons", "r.png") => ("image/png", IMG_ICON_R),
4584 ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
4585 ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
4586 ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
4587 ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
4588 ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
4589 _ => return StatusCode::NOT_FOUND.into_response(),
4590 };
4591 ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
4592}
4593
4594fn authorize_preview_path(state: &AppState, resolved: &Path) -> Result<(), Html<String>> {
4599 let Ok(canonical) = fs::canonicalize(resolved) else {
4604 if !is_upload_tmp_path(resolved) && !is_sample_path(resolved) {
4605 return Err(Html(
4606 r#"<div class="preview-error">Preview rejected: path could not be resolved to a real directory.</div>"#.to_string()
4607 ));
4608 }
4609 return Ok(());
4610 };
4611 if is_upload_tmp_path(&canonical) || is_sample_path(&canonical) {
4613 return Ok(());
4614 }
4615 let config = &state.base_config;
4616 if config.discovery.allowed_scan_roots.is_empty() {
4617 return Err(Html(
4618 r#"<div class="preview-error">Preview rejected: this server has no scan roots configured. Set SLOC_ALLOWED_ROOTS (colon-separated paths) to enable server-side path scanning; the Browse / upload flow works without it.</div>"#.to_string()
4619 ));
4620 }
4621 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
4622 fs::canonicalize(root)
4623 .ok()
4624 .is_some_and(|r| canonical.starts_with(&r))
4625 });
4626 if !allowed {
4627 return Err(Html(
4628 r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
4629 ));
4630 }
4631 Ok(())
4632}
4633
4634async fn preview_handler(
4635 State(state): State<AppState>,
4636 Query(query): Query<PreviewQuery>,
4637) -> impl IntoResponse {
4638 let raw_path = query
4639 .path
4640 .unwrap_or_else(|| "testing/fixtures/basic".to_string());
4641 let resolved = resolve_input_path(&raw_path);
4642
4643 if state.server_mode && is_sample_path(&resolved) && !resolved.exists() {
4647 return Html(
4648 r#"<div class="preview-error">Sample directory not available on this server.
4649 Enter a path to a project directory or upload files using Browse.</div>"#
4650 .to_string(),
4651 );
4652 }
4653
4654 if state.server_mode
4655 && let Err(resp) = authorize_preview_path(&state, &resolved)
4656 {
4657 return resp;
4658 }
4659
4660 let include_patterns = split_patterns(query.include_globs.as_deref());
4661 let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
4662
4663 match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
4664 Ok(html) => Html(html),
4665 Err(err) => Html(format!(
4666 r#"<div class="preview-error">Preview failed: {}</div>"#,
4667 escape_html(&err.to_string())
4668 )),
4669 }
4670}
4671
4672#[derive(Debug, Deserialize, Default)]
4673struct SuggestCoverageQuery {
4674 path: Option<String>,
4675}
4676
4677#[derive(Serialize)]
4678struct SuggestCoverageResponse {
4679 found: Option<String>,
4680 tool: Option<&'static str>,
4681 hint: Option<&'static str>,
4682}
4683
4684async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
4685 const CANDIDATES: &[&str] = &[
4686 "coverage/lcov.info",
4688 "lcov.info",
4689 "target/llvm-cov/lcov.info",
4690 "target/coverage/lcov.info",
4691 "target/debug/coverage/lcov.info",
4692 "coverage/coverage.lcov",
4693 "build/coverage/lcov.info",
4694 "reports/lcov.info",
4695 "coverage.xml",
4697 "coverage/coverage.xml",
4698 "target/site/cobertura/coverage.xml",
4699 "build/reports/coverage/coverage.xml",
4700 "target/site/jacoco/jacoco.xml",
4702 "build/reports/jacoco/test/jacocoTestReport.xml",
4703 "build/reports/jacoco/jacocoTestReport.xml",
4704 "build/jacoco/jacoco.xml",
4705 "coverage.json",
4707 "coverage/coverage.json",
4708 ];
4709 let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
4710 let found = CANDIDATES
4711 .iter()
4712 .map(|rel| root.join(rel))
4713 .find(|p| p.is_file())
4714 .map(|p| display_path(&p));
4715
4716 let (tool, hint) = detect_coverage_tool(&root);
4717 Json(SuggestCoverageResponse { found, tool, hint })
4718}
4719
4720fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
4723 if root.join("Cargo.toml").is_file() {
4724 return (
4725 Some("cargo-llvm-cov"),
4726 Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
4727 );
4728 }
4729 if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
4730 return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
4731 }
4732 if root.join("pom.xml").is_file() {
4733 return (Some("jacoco"), Some("mvn test jacoco:report"));
4734 }
4735 if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
4736 return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
4737 }
4738 (None, None)
4739}
4740
4741#[allow(clippy::result_large_err)]
4743fn validate_server_scan_path(
4744 config: &sloc_config::AppConfig,
4745 resolved_path: &Path,
4746 csp_nonce: &str,
4747) -> Result<(), Response> {
4748 if config.discovery.allowed_scan_roots.is_empty() {
4749 let template = ErrorTemplate {
4750 message: "Scan path rejected: this server has no scan roots configured, so \
4751 scanning server-side paths is disabled. Set the SLOC_ALLOWED_ROOTS \
4752 environment variable (colon-separated absolute paths) — or \
4753 allowed_scan_roots in the config TOML — then restart. Tip: the \
4754 Browse / directory-upload flow works without this; uploaded folders \
4755 are scanned from the server's temp area and bypass this check."
4756 .to_string(),
4757 last_report_url: None,
4758 last_report_label: None,
4759 run_id: None,
4760 error_code: Some(403),
4761 csp_nonce: csp_nonce.to_owned(),
4762 version: env!("CARGO_PKG_VERSION"),
4763 };
4764 return Err((
4765 StatusCode::FORBIDDEN,
4766 Html(
4767 template
4768 .render()
4769 .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
4770 ),
4771 )
4772 .into_response());
4773 }
4774 let Ok(canonical) = fs::canonicalize(resolved_path) else {
4779 tracing::warn!(event = "path_rejected", path = %resolved_path.display(),
4780 "Scan path does not resolve to a real location");
4781 let template = ErrorTemplate {
4782 message: "The requested path could not be resolved to a real directory.".to_string(),
4783 last_report_url: None,
4784 last_report_label: None,
4785 run_id: None,
4786 error_code: Some(403),
4787 csp_nonce: csp_nonce.to_owned(),
4788 version: env!("CARGO_PKG_VERSION"),
4789 };
4790 return Err((
4791 StatusCode::FORBIDDEN,
4792 Html(
4793 template
4794 .render()
4795 .unwrap_or_else(|_| "<pre>Forbidden.</pre>".to_string()),
4796 ),
4797 )
4798 .into_response());
4799 };
4800 let allowed = config.discovery.allowed_scan_roots.iter().any(|root| {
4801 fs::canonicalize(root)
4802 .ok()
4803 .is_some_and(|r| canonical.starts_with(&r))
4804 });
4805 if !allowed {
4806 tracing::warn!(event = "path_rejected", path = %canonical.display(),
4807 "Scan path not in allowed_scan_roots");
4808 let template = ErrorTemplate {
4809 message: "The requested path is not within an allowed scan directory.".to_string(),
4810 last_report_url: None,
4811 last_report_label: None,
4812 run_id: None,
4813 error_code: Some(403),
4814 csp_nonce: csp_nonce.to_owned(),
4815 version: env!("CARGO_PKG_VERSION"),
4816 };
4817 return Err((
4818 StatusCode::FORBIDDEN,
4819 Html(
4820 template
4821 .render()
4822 .unwrap_or_else(|_| "<pre>Path not allowed.</pre>".to_string()),
4823 ),
4824 )
4825 .into_response());
4826 }
4827 Ok(())
4828}
4829
4830fn apply_output_dir_exclusions(
4832 config: &mut sloc_config::AppConfig,
4833 project_path: &str,
4834 raw_output_dir: &str,
4835) {
4836 let project_root = resolve_input_path(project_path);
4837 let raw_out = raw_output_dir.trim();
4838 let resolved_out = if raw_out.is_empty() {
4839 project_root.join("sloc")
4840 } else if Path::new(raw_out).is_absolute() {
4841 PathBuf::from(raw_out)
4842 } else {
4843 workspace_root().join(raw_out)
4844 };
4845 if let Ok(rel) = resolved_out.strip_prefix(&project_root)
4846 && let Some(first) = rel.iter().next().and_then(|c| c.to_str())
4847 {
4848 let dir = first.to_string();
4849 if !config.discovery.excluded_directories.contains(&dir) {
4850 config.discovery.excluded_directories.push(dir);
4851 }
4852 }
4853 if !config
4854 .discovery
4855 .excluded_directories
4856 .iter()
4857 .any(|d| d == "sloc")
4858 {
4859 config
4860 .discovery
4861 .excluded_directories
4862 .push("sloc".to_string());
4863 }
4864}
4865
4866const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
4868 ScanSummarySnapshot {
4869 files_analyzed: run.summary_totals.files_analyzed,
4870 files_skipped: run.summary_totals.files_skipped,
4871 total_physical_lines: run.summary_totals.total_physical_lines,
4872 code_lines: run.summary_totals.code_lines,
4873 comment_lines: run.summary_totals.comment_lines,
4874 blank_lines: run.summary_totals.blank_lines,
4875 functions: run.summary_totals.functions,
4876 classes: run.summary_totals.classes,
4877 variables: run.summary_totals.variables,
4878 imports: run.summary_totals.imports,
4879 test_count: run.summary_totals.test_count,
4880 coverage_lines_found: run.summary_totals.coverage_lines_found,
4881 coverage_lines_hit: run.summary_totals.coverage_lines_hit,
4882 coverage_functions_found: run.summary_totals.coverage_functions_found,
4883 coverage_functions_hit: run.summary_totals.coverage_functions_hit,
4884 coverage_branches_found: run.summary_totals.coverage_branches_found,
4885 coverage_branches_hit: run.summary_totals.coverage_branches_hit,
4886 }
4887}
4888
4889pub(crate) fn build_run_registry_entry(
4891 run: &AnalysisRun,
4892 run_id: &str,
4893 project_label: &str,
4894 artifacts: &RunArtifacts,
4895) -> RegistryEntry {
4896 RegistryEntry {
4897 run_id: run_id.to_owned(),
4898 timestamp_utc: run.tool.timestamp_utc,
4899 project_label: project_label.to_owned(),
4900 input_roots: run.input_roots.clone(),
4901 json_path: artifacts.json_path.clone(),
4902 html_path: artifacts.html_path.clone(),
4903 pdf_path: artifacts.pdf_path.clone(),
4904 csv_path: artifacts.csv_path.clone(),
4905 xlsx_path: artifacts.xlsx_path.clone(),
4906 summary: summary_snapshot_from_run(run),
4907 git_branch: run.git_branch.clone(),
4908 git_commit: run.git_commit_short.clone(),
4909 git_commit_long: run.git_commit_long.clone(),
4910 git_author: run.git_commit_author.clone(),
4911 git_tags: run.git_tags.clone(),
4912 git_nearest_tag: run.git_nearest_tag.clone(),
4913 git_commit_date: run.git_commit_date.clone(),
4914 }
4915}
4916
4917fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4919 if let Some(policy) = form.mixed_line_policy {
4920 config.analysis.mixed_line_policy = policy;
4921 }
4922 config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
4923 config.analysis.generated_file_detection =
4924 form.generated_file_detection.as_deref() != Some("disabled");
4925 config.analysis.minified_file_detection =
4926 form.minified_file_detection.as_deref() != Some("disabled");
4927 config.analysis.vendor_directory_detection =
4928 form.vendor_directory_detection.as_deref() != Some("disabled");
4929 config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
4930 if let Some(binary_behavior) = form.binary_file_behavior {
4931 config.analysis.binary_file_behavior = binary_behavior;
4932 }
4933 apply_report_opts(config, form);
4934 config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
4935 config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
4936 config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
4937 if let Some(policy) = form.continuation_line_policy {
4938 config.analysis.continuation_line_policy = policy;
4939 }
4940 if let Some(policy) = form.blank_in_block_comment_policy {
4941 config.analysis.blank_in_block_comment_policy = policy;
4942 }
4943 config.analysis.count_compiler_directives =
4944 form.count_compiler_directives.as_deref() != Some("disabled");
4945 apply_style_threshold(config, form);
4946 apply_coverage_path(config, form);
4947}
4948
4949fn apply_report_opts(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4950 if let Some(report_title) = form.report_title.as_deref() {
4951 let trimmed = report_title.trim();
4952 if !trimmed.is_empty() {
4953 config.reporting.report_title = trimmed.to_string();
4954 }
4955 }
4956 if let Some(hf) = form.report_header_footer.as_deref() {
4957 let trimmed = hf.trim();
4958 config.reporting.report_header_footer = if trimmed.is_empty() {
4959 None
4960 } else {
4961 Some(trimmed.to_string())
4962 };
4963 }
4964}
4965
4966fn apply_style_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4967 apply_style_col_threshold(config, form);
4968 apply_style_analysis_enabled(config, form);
4969 apply_style_score_threshold(config, form);
4970 apply_style_lang_scope(config, form);
4971 apply_activity_window(config, form);
4972}
4973
4974fn apply_style_col_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4975 if let Some(threshold_str) = form.style_col_threshold.as_deref()
4976 && let Ok(t) = threshold_str.parse::<u16>()
4977 && (t == 80 || t == 100 || t == 120)
4978 {
4979 config.analysis.style_col_threshold = t;
4980 }
4981}
4982
4983fn apply_style_analysis_enabled(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4984 if let Some(v) = form.style_analysis_enabled.as_deref() {
4985 config.analysis.style_analysis_enabled = v != "disabled";
4986 }
4987}
4988
4989fn apply_style_score_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4990 if let Some(v) = form.style_score_threshold.as_deref()
4991 && let Ok(t) = v.parse::<u8>()
4992 {
4993 config.analysis.style_score_threshold = t.min(100);
4994 }
4995}
4996
4997fn apply_style_lang_scope(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
4998 if let Some(v) = form.style_lang_scope.as_deref() {
4999 let scope = v.trim();
5000 if scope == "c_family" || scope == "all" {
5001 config.analysis.style_lang_scope = scope.to_string();
5002 }
5003 }
5004}
5005
5006fn apply_activity_window(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5007 if let Some(w) = form.activity_window.as_deref() {
5010 let w = w.trim();
5011 if !w.is_empty()
5012 && let Ok(days) = w.parse::<u32>()
5013 {
5014 config.analysis.activity_window_days = Some(days);
5015 }
5016 }
5017}
5018
5019fn apply_coverage_path(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
5020 if let Some(cov) = &form.coverage_file {
5021 let trimmed = cov.trim();
5022 if !trimmed.is_empty() {
5023 config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
5024 }
5025 }
5026}
5027
5028fn spawn_pdf_background(
5032 pending_pdf: PendingPdf,
5033 run_id: String,
5034 artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
5035) {
5036 if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
5037 tokio::spawn(async move {
5038 let result = tokio::task::spawn_blocking(move || {
5039 let r = write_pdf_from_html(&pdf_src, &pdf_dst);
5040 if cleanup_src {
5041 let _ = fs::remove_file(&pdf_src);
5042 }
5043 r
5044 })
5045 .await;
5046 let failed = match result {
5047 Ok(Ok(())) => false,
5048 Ok(Err(err)) => {
5049 eprintln!("[oxide-sloc][pdf] background PDF failed: {err}");
5050 true
5051 }
5052 Err(err) => {
5053 eprintln!("[oxide-sloc][pdf] background PDF task panicked: {err}");
5054 true
5055 }
5056 };
5057 if failed {
5058 let mut map = artifacts.lock().await;
5059 if let Some(entry) = map.get_mut(&run_id) {
5060 entry.pdf_path = None;
5061 }
5062 }
5063 });
5064 }
5065}
5066
5067fn spawn_native_pdf_background(
5071 json_path: PathBuf,
5072 pdf_dest: PathBuf,
5073 run_id: String,
5074 artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
5075) {
5076 tokio::spawn(async move {
5077 let result = tokio::task::spawn_blocking(move || {
5078 let run = sloc_core::read_json(&json_path)?;
5079 write_pdf_from_run(&run, &pdf_dest)
5080 })
5081 .await;
5082 let failed = match result {
5083 Ok(Ok(())) => false,
5084 Ok(Err(err)) => {
5085 eprintln!("[oxide-sloc][pdf] on-demand PDF failed: {err}");
5086 true
5087 }
5088 Err(err) => {
5089 eprintln!("[oxide-sloc][pdf] on-demand PDF task panicked: {err}");
5090 true
5091 }
5092 };
5093 if failed {
5094 let mut map = artifacts.lock().await;
5095 if let Some(entry) = map.get_mut(&run_id) {
5096 entry.pdf_path = None;
5097 }
5098 }
5099 });
5100}
5101
5102fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5104 cmp.file_deltas
5105 .iter()
5106 .map(|f| match f.status {
5107 FileChangeStatus::Added => f.current_code,
5108 FileChangeStatus::Modified => f.code_delta.max(0),
5109 _ => 0,
5110 })
5111 .sum()
5112}
5113
5114fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5116 cmp.file_deltas
5117 .iter()
5118 .map(|f| match f.status {
5119 FileChangeStatus::Removed => f.baseline_code,
5120 FileChangeStatus::Modified => (-f.code_delta).max(0),
5121 _ => 0,
5122 })
5123 .sum()
5124}
5125
5126fn sum_unmodified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5128 cmp.file_deltas
5129 .iter()
5130 .filter(|f| f.status == FileChangeStatus::Unchanged)
5131 .map(|f| f.current_code)
5132 .sum()
5133}
5134
5135fn sum_modified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
5137 cmp.file_deltas
5138 .iter()
5139 .filter(|f| f.status == FileChangeStatus::Modified)
5140 .map(|f| f.current_code)
5141 .sum()
5142}
5143
5144fn build_submodule_row(
5146 s: &sloc_core::SubmoduleSummary,
5147 run: &AnalysisRun,
5148 run_id: &str,
5149 run_dir: &Path,
5150) -> SubmoduleRow {
5151 let safe = sanitize_project_label(&s.name);
5152 let artifact_key = format!("sub_{safe}");
5153 let pdf_artifact_key = format!("sub_{safe}_pdf");
5154 let html_url = if run.effective_configuration.discovery.submodule_breakdown {
5155 let parent_path = run
5156 .input_roots
5157 .first()
5158 .map_or("", std::string::String::as_str);
5159 let sub_run = build_sub_run(run, s, parent_path);
5160 let pdf_server_url = format!("/runs/{pdf_artifact_key}/{run_id}");
5161 render_sub_report_html(&sub_run, Some(&pdf_server_url))
5162 .ok()
5163 .and_then(|sub_html| {
5164 let sub_dir = run_dir.join("submodules");
5165 let _ = fs::create_dir_all(&sub_dir);
5166 let html_path = sub_dir.join(format!("{artifact_key}.html"));
5167 if fs::write(&html_path, sub_html.as_bytes()).is_ok() {
5168 let pdf_path = sub_dir.join(format!("{artifact_key}.pdf"));
5171 let _ = write_pdf_from_run(&sub_run, &pdf_path);
5172 Some(format!("/runs/{artifact_key}/{run_id}"))
5173 } else {
5174 None
5175 }
5176 })
5177 } else {
5178 None
5179 };
5180 SubmoduleRow {
5181 name: s.name.clone(),
5182 relative_path: s.relative_path.clone(),
5183 files_analyzed: s.files_analyzed,
5184 code_lines: s.code_lines,
5185 comment_lines: s.comment_lines,
5186 blank_lines: s.blank_lines,
5187 total_physical_lines: s.total_physical_lines,
5188 html_url,
5189 }
5190}
5191
5192#[allow(clippy::similar_names)]
5195#[allow(clippy::significant_drop_tightening)] #[allow(clippy::too_many_lines)]
5197async fn analyze_handler(
5198 State(state): State<AppState>,
5199 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5200 Form(form): Form<AnalyzeForm>,
5201) -> impl IntoResponse {
5202 let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
5203 let template = ErrorTemplate {
5204 message: format!(
5205 "Server is busy — all {MAX_CONCURRENT_ANALYSES} analysis slots are in use. \
5206 Please wait a moment and try again."
5207 ),
5208 last_report_url: None,
5209 last_report_label: None,
5210 run_id: None,
5211 error_code: Some(503),
5212 csp_nonce: csp_nonce.clone(),
5213 version: env!("CARGO_PKG_VERSION"),
5214 };
5215 return (
5216 StatusCode::SERVICE_UNAVAILABLE,
5217 Html(
5218 template
5219 .render()
5220 .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
5221 ),
5222 )
5223 .into_response();
5224 };
5225
5226 let mut config = state.base_config.clone();
5227
5228 let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
5229 let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
5230 let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
5231
5232 if !is_git_mode {
5233 let resolved_path = resolve_input_path(&form.path);
5234 if state.server_mode
5235 && !is_upload_tmp_path(&resolved_path)
5236 && !is_sample_path(&resolved_path)
5237 && let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce)
5238 {
5239 return resp;
5240 }
5241 config.discovery.root_paths = vec![resolved_path];
5242 }
5243
5244 apply_form_to_config(&mut config, &form);
5245 apply_output_dir_exclusions(
5246 &mut config,
5247 &form.path,
5248 form.output_dir.as_deref().unwrap_or(""),
5249 );
5250
5251 let wait_id = uuid::Uuid::new_v4().to_string();
5253 let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
5254
5255 let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
5257 let task_cancel = Arc::clone(&cancel_token);
5258
5259 let phase = Arc::new(std::sync::Mutex::new("Starting".to_string()));
5261 let task_phase = Arc::clone(&phase);
5262
5263 let files_done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5264 let files_total = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5265 let task_files_done = Arc::clone(&files_done);
5266 let task_files_total = Arc::clone(&files_total);
5267
5268 {
5271 let mut runs = state.async_runs.lock().await;
5272 runs.insert(
5273 wait_id.clone(),
5274 AsyncRunState::Running {
5275 started_at: std::time::Instant::now(),
5276 cancel_token,
5277 phase,
5278 files_done,
5279 files_total,
5280 },
5281 );
5282 }
5283
5284 let task = AnalysisTask {
5285 sem_permit,
5286 state: state.clone(),
5287 wait_id: wait_id.clone(),
5288 config,
5289 cancel: task_cancel,
5290 phase: task_phase,
5291 files_done: task_files_done,
5292 files_total: task_files_total,
5293 git_repo: form.git_repo.clone().filter(|s| !s.is_empty()),
5294 git_ref: form.git_ref.clone().filter(|s| !s.is_empty()),
5295 project_path: form.path.clone(),
5296 output_dir: if state.server_mode {
5300 None
5301 } else {
5302 form.output_dir.clone()
5303 },
5304 clones_dir: state.git_clones_dir.clone(),
5305 cocomo_mode: form
5306 .cocomo_mode
5307 .clone()
5308 .unwrap_or_else(|| "organic".to_string()),
5309 complexity_alert: form
5310 .complexity_alert
5311 .as_deref()
5312 .and_then(|s| s.parse::<u32>().ok())
5313 .unwrap_or(0),
5314 exclude_duplicates: form.exclude_duplicates.as_deref() == Some("enabled"),
5315 };
5316
5317 tokio::spawn(run_analysis_task(task));
5318
5319 let template = ScanWaitTemplate {
5320 version: env!("CARGO_PKG_VERSION"),
5321 wait_id_json,
5322 project_path: form.path.clone(),
5323 csp_nonce,
5324 };
5325 let html = template
5326 .render()
5327 .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
5328 let mut response = Html(html).into_response();
5329 if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id")
5330 && let Ok(val) = axum::http::HeaderValue::from_str(&wait_id)
5331 {
5332 response.headers_mut().insert(name, val);
5333 }
5334 response
5335}
5336
5337struct AnalysisTask {
5338 sem_permit: tokio::sync::OwnedSemaphorePermit,
5339 state: AppState,
5340 wait_id: String,
5341 config: AppConfig,
5342 cancel: Arc<std::sync::atomic::AtomicBool>,
5343 phase: Arc<std::sync::Mutex<String>>,
5344 files_done: Arc<std::sync::atomic::AtomicUsize>,
5345 files_total: Arc<std::sync::atomic::AtomicUsize>,
5346 git_repo: Option<String>,
5347 git_ref: Option<String>,
5348 project_path: String,
5349 output_dir: Option<String>,
5350 clones_dir: PathBuf,
5351 cocomo_mode: String,
5352 complexity_alert: u32,
5353 exclude_duplicates: bool,
5354}
5355
5356#[allow(clippy::too_many_lines)] async fn run_analysis_task(task: AnalysisTask) {
5358 let _permit = task.sem_permit;
5359
5360 let cancel_sb = Arc::clone(&task.cancel);
5361 let (git_repo_sb, git_ref_sb) = (task.git_repo.clone(), task.git_ref.clone());
5362 let clones_dir_sb = task.clones_dir;
5363 let upload_staging_root = task
5365 .config
5366 .discovery
5367 .root_paths
5368 .first()
5369 .filter(|p| is_upload_tmp_path(p))
5370 .and_then(|p| p.parent().filter(|par| is_upload_tmp_path(par)))
5371 .map(PathBuf::from);
5372 let config_sb = task.config;
5373 let progress_sb = sloc_core::ProgressCounters {
5374 files_done: Arc::clone(&task.files_done),
5375 files_total: Arc::clone(&task.files_total),
5376 };
5377 if let Ok(mut p) = task.phase.lock() {
5378 *p = "Scanning files".to_string();
5379 }
5380 let analysis_result = tokio::task::spawn_blocking(move || {
5381 run_analysis_blocking(
5382 config_sb,
5383 git_repo_sb,
5384 git_ref_sb,
5385 clones_dir_sb,
5386 cancel_sb,
5387 Some(progress_sb),
5388 )
5389 })
5390 .await
5391 .map_err(|err| anyhow::anyhow!(err.to_string()))
5392 .and_then(|result| result);
5393
5394 if let Ok(mut p) = task.phase.lock() {
5395 *p = "Writing reports".to_string();
5396 }
5397
5398 if task.cancel.load(std::sync::atomic::Ordering::Relaxed) {
5400 let mut runs = task.state.async_runs.lock().await;
5401 if matches!(
5403 runs.get(&task.wait_id),
5404 Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
5405 ) {
5406 runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
5407 }
5408 drop(runs);
5409 return;
5410 }
5411
5412 let run = match analysis_result {
5413 Ok(v) => v,
5414 Err(err) => {
5415 if err.to_string().contains("analysis cancelled") {
5417 let mut runs = task.state.async_runs.lock().await;
5418 runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
5419 drop(runs);
5420 return;
5421 }
5422 eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
5423 let mut runs = task.state.async_runs.lock().await;
5424 runs.insert(
5425 task.wait_id.clone(),
5426 AsyncRunState::Failed {
5427 message: "Analysis failed. Check that the path exists and is readable."
5428 .to_string(),
5429 },
5430 );
5431 drop(runs);
5432 return;
5433 }
5434 };
5435
5436 let run_id = run.tool.run_id.clone();
5437 tracing::info!(event = "scan_complete", run_id = %run_id,
5438 path = %task.project_path, files = run.summary_totals.files_analyzed,
5439 "Analysis finished");
5440
5441 let prev_entry: Option<RegistryEntry> = {
5442 let reg = task.state.registry.lock().await;
5443 reg.entries_for_roots(&run.input_roots)
5444 .into_iter()
5445 .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
5446 .cloned()
5447 };
5448
5449 let scan_delta = prev_entry.as_ref().and_then(|prev| {
5450 prev.json_path
5451 .as_ref()
5452 .and_then(|p| read_json(p).ok())
5453 .map(|prev_run| compute_delta(&prev_run, &run))
5454 });
5455 let prev_scan_count: usize = {
5456 let reg = task.state.registry.lock().await;
5457 reg.entries_for_roots(&run.input_roots)
5458 .iter()
5459 .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
5460 .count()
5461 };
5462
5463 let report_delta_ctx: Option<ReportDeltaContext> = scan_delta
5466 .as_ref()
5467 .zip(prev_entry.as_ref())
5468 .map(|(cmp, prev)| ReportDeltaContext {
5469 delta_code_added: sum_added_code_lines(cmp),
5470 delta_code_removed: sum_removed_code_lines(cmp),
5471 delta_unmodified_lines: sum_unmodified_code_lines(cmp),
5472 delta_files_added: cmp.files_added,
5473 delta_files_removed: cmp.files_removed,
5474 delta_files_modified: cmp.files_modified,
5475 delta_files_unchanged: cmp.files_unchanged,
5476 prev_code_lines: prev.summary.code_lines,
5477 prev_scan_count: prev_scan_count + 1,
5478 prev_scan_label: fmt_la_time(prev.timestamp_utc),
5479 prev_run_id: Some(prev.run_id.clone()),
5480 current_run_id: Some(run_id.clone()),
5481 });
5482 let report_html = match render_html_with_delta(&run, report_delta_ctx.as_ref()) {
5483 Ok(h) => h,
5484 Err(err) => {
5485 eprintln!("[oxide-sloc][analyze] HTML render failed: {err:#}");
5486 let mut runs = task.state.async_runs.lock().await;
5487 runs.insert(
5488 task.wait_id.clone(),
5489 AsyncRunState::Failed {
5490 message: "Failed to render HTML report.".to_string(),
5491 },
5492 );
5493 drop(runs);
5494 return;
5495 }
5496 };
5497
5498 let output_root = resolve_output_root(task.output_dir.as_deref());
5499 let project_label = derive_project_label(
5500 task.git_repo.as_deref(),
5501 task.git_ref.as_deref(),
5502 &task.project_path,
5503 );
5504 let run_dir = output_root.join(format!("{project_label}_{run_id}"));
5505 let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
5506
5507 let result_context = RunResultContext {
5508 prev_entry: prev_entry.clone(),
5509 prev_scan_count,
5510 project_path: task.project_path.clone(),
5511 cocomo_mode: task.cocomo_mode.clone(),
5512 complexity_alert: task.complexity_alert,
5513 exclude_duplicates: task.exclude_duplicates,
5514 };
5515
5516 let artifact_result = persist_run_artifacts(
5517 &run,
5518 &report_html,
5519 &run_dir,
5520 &run.effective_configuration.reporting.report_title,
5521 &file_stem,
5522 result_context,
5523 );
5524
5525 let (artifacts, pending_pdf) = match artifact_result {
5526 Ok(v) => v,
5527 Err(err) => {
5528 eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
5529 let mut runs = task.state.async_runs.lock().await;
5530 runs.insert(
5531 task.wait_id.clone(),
5532 AsyncRunState::Failed {
5533 message: "Failed to save report artifacts. Check available disk space."
5534 .to_string(),
5535 },
5536 );
5537 drop(runs);
5538 return;
5539 }
5540 };
5541
5542 {
5543 let mut map = task.state.artifacts.lock().await;
5544 map.insert(run_id.clone(), artifacts.clone());
5545 }
5546
5547 {
5548 let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
5549 let mut reg = task.state.registry.lock().await;
5550 reg.add_entry(entry);
5551 let _ = reg.save(&task.state.registry_path);
5552 }
5553
5554 if let Some(ref cfg_path) = artifacts.scan_config_path {
5555 save_scan_config_json(
5556 cfg_path,
5557 &run,
5558 &task.project_path,
5559 task.output_dir.as_deref(),
5560 &task.cocomo_mode,
5561 task.complexity_alert,
5562 task.exclude_duplicates,
5563 );
5564 }
5565
5566 spawn_pdf_background(pending_pdf, run_id.clone(), task.state.artifacts.clone());
5567
5568 prom_runs_total().inc();
5569
5570 let mut runs = task.state.async_runs.lock().await;
5572 runs.insert(
5573 task.wait_id.clone(),
5574 AsyncRunState::Complete {
5575 run_id: run_id.clone(),
5576 },
5577 );
5578 drop(runs);
5579
5580 if let Some(staging) = upload_staging_root {
5583 let _ = tokio::fs::remove_dir_all(staging).await;
5584 }
5585
5586 let _ = scan_delta;
5587}
5588
5589fn save_scan_config_json(
5590 cfg_path: &std::path::Path,
5591 run: &sloc_core::AnalysisRun,
5592 project_path: &str,
5593 output_dir: Option<&str>,
5594 cocomo_mode: &str,
5595 complexity_alert: u32,
5596 exclude_duplicates: bool,
5597) {
5598 let policy_str = serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
5599 .ok()
5600 .and_then(|v| v.as_str().map(String::from))
5601 .unwrap_or_else(|| "code_only".to_string());
5602 let behavior_str =
5603 serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
5604 .ok()
5605 .and_then(|v| v.as_str().map(String::from))
5606 .unwrap_or_else(|| "skip".to_string());
5607 let continuation_policy_str = serde_json::to_value(
5608 run.effective_configuration
5609 .analysis
5610 .continuation_line_policy,
5611 )
5612 .ok()
5613 .and_then(|v| v.as_str().map(String::from))
5614 .unwrap_or_else(default_each_physical_line);
5615 let blank_policy_str = serde_json::to_value(
5616 run.effective_configuration
5617 .analysis
5618 .blank_in_block_comment_policy,
5619 )
5620 .ok()
5621 .and_then(|v| v.as_str().map(String::from))
5622 .unwrap_or_else(default_count_as_comment);
5623 let scan_cfg = ScanConfig {
5624 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
5625 path: project_path.to_string(),
5626 include_globs: run
5627 .effective_configuration
5628 .discovery
5629 .include_globs
5630 .join("\n"),
5631 exclude_globs: run
5632 .effective_configuration
5633 .discovery
5634 .exclude_globs
5635 .join("\n"),
5636 submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
5637 mixed_line_policy: policy_str,
5638 python_docstrings_as_comments: run
5639 .effective_configuration
5640 .analysis
5641 .python_docstrings_as_comments,
5642 generated_file_detection: run
5643 .effective_configuration
5644 .analysis
5645 .generated_file_detection,
5646 minified_file_detection: run.effective_configuration.analysis.minified_file_detection,
5647 vendor_directory_detection: run
5648 .effective_configuration
5649 .analysis
5650 .vendor_directory_detection,
5651 include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
5652 binary_file_behavior: behavior_str,
5653 output_dir: output_dir.unwrap_or("").to_string(),
5654 report_title: run.effective_configuration.reporting.report_title.clone(),
5655 continuation_line_policy: continuation_policy_str,
5656 blank_in_block_comment_policy: blank_policy_str,
5657 count_compiler_directives: run
5658 .effective_configuration
5659 .analysis
5660 .count_compiler_directives,
5661 style_analysis_enabled: run.effective_configuration.analysis.style_analysis_enabled,
5662 style_col_threshold: run.effective_configuration.analysis.style_col_threshold,
5663 style_score_threshold: run.effective_configuration.analysis.style_score_threshold,
5664 style_lang_scope: run
5665 .effective_configuration
5666 .analysis
5667 .style_lang_scope
5668 .clone(),
5669 coverage_file: run
5670 .effective_configuration
5671 .analysis
5672 .coverage_file
5673 .as_ref()
5674 .map(|p| p.display().to_string())
5675 .unwrap_or_default(),
5676 cocomo_mode: cocomo_mode.to_string(),
5677 complexity_alert,
5678 exclude_duplicates,
5679 activity_window: run
5680 .effective_configuration
5681 .analysis
5682 .activity_window_days
5683 .unwrap_or(0),
5684 };
5685 if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
5686 let _ = std::fs::write(cfg_path, json);
5687 }
5688}
5689
5690#[allow(clippy::needless_pass_by_value)] fn run_analysis_blocking(
5692 mut config: AppConfig,
5693 git_repo: Option<String>,
5694 git_ref: Option<String>,
5695 clones_dir: PathBuf,
5696 cancel: Arc<std::sync::atomic::AtomicBool>,
5697 progress: Option<sloc_core::ProgressCounters>,
5698) -> Result<sloc_core::AnalysisRun> {
5699 if let (Some(repo), Some(refname)) = (git_repo, git_ref) {
5700 let dest = git_clone_dest(&repo, &clones_dir);
5701 sloc_git::clone_or_fetch(&repo, &dest)?;
5702 let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
5703 sloc_git::create_worktree(&dest, &refname, &wt)?;
5704 config.discovery.root_paths = vec![wt.clone()];
5705 let run = analyze(&config, "serve", Some(&cancel), progress.as_ref());
5706 let _ = sloc_git::destroy_worktree(&dest, &wt);
5707 let mut run = run?;
5708 if run.git_branch.is_none() {
5709 run.git_branch = Some(refname);
5710 }
5711 return Ok(run);
5712 }
5713 analyze(&config, "serve", Some(&cancel), progress.as_ref())
5714}
5715
5716fn derive_project_label(
5717 git_repo: Option<&str>,
5718 git_ref: Option<&str>,
5719 fallback_path: &str,
5720) -> String {
5721 match (
5722 git_repo.filter(|s| !s.is_empty()),
5723 git_ref.filter(|s| !s.is_empty()),
5724 ) {
5725 (Some(repo), Some(refname)) => {
5726 let repo_name = repo
5727 .trim_end_matches('/')
5728 .trim_end_matches(".git")
5729 .rsplit('/')
5730 .next()
5731 .unwrap_or("repo");
5732 sanitize_project_label(&format!("{repo_name}_{refname}"))
5733 }
5734 _ => sanitize_project_label(fallback_path),
5735 }
5736}
5737
5738fn derive_file_stem(project_label: &str, commit_short: Option<&str>) -> String {
5739 let commit = commit_short.unwrap_or("").trim();
5740 if commit.is_empty() {
5741 project_label.to_string()
5742 } else {
5743 format!("{project_label}_{commit}")
5744 }
5745}
5746
5747#[derive(Serialize)]
5750#[serde(tag = "state", rename_all = "snake_case")]
5751enum AsyncRunStatusResponse {
5752 Running {
5753 elapsed_secs: u64,
5754 phase: String,
5755 files_done: u64,
5756 files_total: u64,
5757 },
5758 Complete {
5759 run_id: String,
5760 },
5761 Failed {
5762 message: String,
5763 },
5764 Cancelled,
5765}
5766
5767async fn async_run_status_handler(
5768 State(state): State<AppState>,
5769 AxumPath(wait_id): AxumPath<String>,
5770) -> Response {
5771 if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
5773 return error::bad_request("invalid wait_id");
5774 }
5775 let run_state = {
5776 let runs = state.async_runs.lock().await;
5777 runs.get(&wait_id).cloned()
5778 };
5779 match run_state {
5780 None => error::not_found("run not found"),
5781 Some(AsyncRunState::Running {
5782 started_at,
5783 phase,
5784 files_done,
5785 files_total,
5786 ..
5787 }) => {
5788 if started_at.elapsed() > std::time::Duration::from_hours(2) {
5790 let mut runs = state.async_runs.lock().await;
5791 runs.insert(
5792 wait_id,
5793 AsyncRunState::Failed {
5794 message: "Analysis timed out after 2 hours.".to_string(),
5795 },
5796 );
5797 drop(runs);
5798 return Json(AsyncRunStatusResponse::Failed {
5799 message: "Analysis timed out after 2 hours.".to_string(),
5800 })
5801 .into_response();
5802 }
5803 let phase_str = phase.lock().map(|g| g.clone()).unwrap_or_default();
5804 Json(AsyncRunStatusResponse::Running {
5805 elapsed_secs: started_at.elapsed().as_secs(),
5806 phase: phase_str,
5807 files_done: files_done.load(std::sync::atomic::Ordering::Relaxed) as u64,
5808 files_total: files_total.load(std::sync::atomic::Ordering::Relaxed) as u64,
5809 })
5810 .into_response()
5811 }
5812 Some(AsyncRunState::Complete { run_id }) => {
5813 Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
5814 }
5815 Some(AsyncRunState::Failed { message }) => {
5816 Json(AsyncRunStatusResponse::Failed { message }).into_response()
5817 }
5818 Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
5819 }
5820}
5821
5822async fn cancel_run_handler(
5823 State(state): State<AppState>,
5824 AxumPath(wait_id): AxumPath<String>,
5825) -> Response {
5826 if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
5827 return error::bad_request("invalid wait_id");
5828 }
5829 let mut runs = state.async_runs.lock().await;
5830 let resp = match runs.get(&wait_id) {
5831 Some(AsyncRunState::Running { cancel_token, .. }) => {
5832 cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
5833 runs.insert(wait_id, AsyncRunState::Cancelled);
5834 StatusCode::OK.into_response()
5835 }
5836 Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
5837 _ => error::not_found("run not found"),
5838 };
5839 drop(runs);
5840 resp
5841}
5842
5843async fn async_run_result_handler(
5844 State(state): State<AppState>,
5845 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
5846 AxumPath(run_id): AxumPath<String>,
5847) -> Response {
5848 if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
5849 return StatusCode::BAD_REQUEST.into_response();
5850 }
5851
5852 let artifacts = {
5853 let map = state.artifacts.lock().await;
5854 map.get(&run_id).cloned()
5855 };
5856 let artifacts = if let Some(a) = artifacts {
5857 a
5858 } else {
5859 let reg = state.registry.lock().await;
5860 if let Some(entry) = reg.find_by_run_id(&run_id) {
5861 recover_artifacts_from_registry(entry)
5862 } else {
5863 let html = ErrorTemplate {
5864 message: format!(
5865 "Report not found. Run ID {} is not in the scan history.",
5866 &run_id[..run_id.len().min(8)]
5867 ),
5868 last_report_url: Some("/view-reports".to_string()),
5869 last_report_label: Some("View Reports".to_string()),
5870 run_id: Some(run_id.clone()),
5871 error_code: Some(404),
5872 csp_nonce: csp_nonce.clone(),
5873 version: env!("CARGO_PKG_VERSION"),
5874 }
5875 .render()
5876 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
5877 return (StatusCode::NOT_FOUND, Html(html)).into_response();
5878 }
5879 };
5880
5881 let json_path = if let Some(p) = &artifacts.json_path {
5882 p.clone()
5883 } else {
5884 let html = ErrorTemplate {
5885 message: "JSON result was not saved for this run.".to_string(),
5886 last_report_url: Some("/view-reports".to_string()),
5887 last_report_label: Some("View Reports".to_string()),
5888 run_id: Some(run_id.clone()),
5889 error_code: Some(404),
5890 csp_nonce: csp_nonce.clone(),
5891 version: env!("CARGO_PKG_VERSION"),
5892 }
5893 .render()
5894 .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
5895 return (StatusCode::NOT_FOUND, Html(html)).into_response();
5896 };
5897
5898 let Ok(run) = read_json(&json_path) else {
5899 let folder_hint = output_folder_hint(&json_path);
5900 let redirect_url = format!("/runs/result/{run_id}");
5901 return missing_scan_relocate_response(
5902 &format!(
5903 "Scan file could not be read:\n {}\n\nThe file may have been moved or \
5904 deleted. Browse to the folder containing your scan output to reconnect it.",
5905 json_path.display()
5906 ),
5907 &run_id,
5908 &folder_hint,
5909 &redirect_url,
5910 state.server_mode,
5911 &csp_nonce,
5912 );
5913 };
5914
5915 let confluence_configured = {
5916 let store = state.confluence.lock().await;
5917 store.is_configured()
5918 };
5919
5920 render_result_page(
5921 &run,
5922 &artifacts,
5923 &run_id,
5924 &csp_nonce,
5925 confluence_configured,
5926 state.server_mode,
5927 )
5928}
5929
5930fn json_escape(s: &str) -> String {
5932 s.replace('\\', "\\\\").replace('"', "\\\"")
5933}
5934
5935struct LangTotals {
5937 physical_lines: u64,
5938 code_lines: u64,
5939 comment_lines: u64,
5940 blank_lines: u64,
5941 mixed_lines: u64,
5942 functions: u64,
5943 classes: u64,
5944 variables: u64,
5945 imports: u64,
5946}
5947
5948fn sum_lang_totals(run: &AnalysisRun) -> LangTotals {
5949 let s = |f: fn(&sloc_core::LanguageSummary) -> u64| -> u64 {
5950 run.totals_by_language.iter().map(f).sum()
5951 };
5952 LangTotals {
5953 physical_lines: s(|r| r.total_physical_lines),
5954 code_lines: s(|r| r.code_lines),
5955 comment_lines: s(|r| r.comment_lines),
5956 blank_lines: s(|r| r.blank_lines),
5957 mixed_lines: s(|r| r.mixed_lines_separate),
5958 functions: s(|r| r.functions),
5959 classes: s(|r| r.classes),
5960 variables: s(|r| r.variables),
5961 imports: s(|r| r.imports),
5962 }
5963}
5964
5965struct DeltaFields {
5967 prev_fa_str: String,
5968 prev_fs_str: String,
5969 prev_pl_str: String,
5970 prev_cl_str: String,
5971 prev_cml_str: String,
5972 prev_bl_str: String,
5973 delta_fa_str: String,
5974 delta_fa_class: String,
5975 delta_fs_str: String,
5976 delta_fs_class: String,
5977 delta_pl_str: String,
5978 delta_pl_class: String,
5979 delta_cl_str: String,
5980 delta_cl_class: String,
5981 delta_cml_str: String,
5982 delta_cml_class: String,
5983 delta_bl_str: String,
5984 delta_bl_class: String,
5985 delta_lines_added: Option<i64>,
5986 delta_lines_removed: Option<i64>,
5987 delta_lines_net_str: String,
5988 delta_lines_net_class: String,
5989}
5990
5991#[allow(
5996 clippy::similar_names,
5997 reason = "locals mirror template-bound struct fields"
5998)]
5999fn compute_delta_fields(
6000 prev_entry: Option<&RegistryEntry>,
6001 totals: &LangTotals,
6002 files_analyzed: u64,
6003 files_skipped: u64,
6004 scan_delta: Option<&sloc_core::ScanComparison>,
6005) -> DeltaFields {
6006 let prev_sum = prev_entry.map(|e| &e.summary);
6007 let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "\u{2014}".into(), |v| v.to_string());
6008
6009 let (delta_fa_str, delta_fa_class) =
6010 summary_delta(files_analyzed, prev_sum.map(|s| s.files_analyzed));
6011 let (delta_fs_str, delta_fs_class) =
6012 summary_delta(files_skipped, prev_sum.map(|s| s.files_skipped));
6013 let (delta_pl_str, delta_pl_class) = summary_delta(
6014 totals.physical_lines,
6015 prev_sum.map(|s| s.total_physical_lines),
6016 );
6017 let (delta_cl_str, delta_cl_class) =
6018 summary_delta(totals.code_lines, prev_sum.map(|s| s.code_lines));
6019 let (delta_cml_str, delta_cml_class) =
6020 summary_delta(totals.comment_lines, prev_sum.map(|s| s.comment_lines));
6021 let (delta_bl_str, delta_bl_class) =
6022 summary_delta(totals.blank_lines, prev_sum.map(|s| s.blank_lines));
6023
6024 let delta_lines_added = scan_delta.map(sum_added_code_lines);
6025 let delta_lines_removed = scan_delta.map(sum_removed_code_lines);
6026 let (delta_lines_net_str, delta_lines_net_class) =
6027 match (delta_lines_added, delta_lines_removed) {
6028 (Some(a), Some(r)) => {
6029 let net = a - r;
6030 (fmt_delta(net), delta_class(net).to_string())
6031 }
6032 _ => ("\u{2014}".to_string(), "na".to_string()),
6033 };
6034
6035 DeltaFields {
6036 prev_fa_str: fmt_prev(prev_sum.map(|s| s.files_analyzed)),
6037 prev_fs_str: fmt_prev(prev_sum.map(|s| s.files_skipped)),
6038 prev_pl_str: fmt_prev(prev_sum.map(|s| s.total_physical_lines)),
6039 prev_cl_str: fmt_prev(prev_sum.map(|s| s.code_lines)),
6040 prev_cml_str: fmt_prev(prev_sum.map(|s| s.comment_lines)),
6041 prev_bl_str: fmt_prev(prev_sum.map(|s| s.blank_lines)),
6042 delta_fa_str,
6043 delta_fa_class: delta_fa_class.to_string(),
6044 delta_fs_str,
6045 delta_fs_class: delta_fs_class.to_string(),
6046 delta_pl_str,
6047 delta_pl_class: delta_pl_class.to_string(),
6048 delta_cl_str,
6049 delta_cl_class: delta_cl_class.to_string(),
6050 delta_cml_str,
6051 delta_cml_class: delta_cml_class.to_string(),
6052 delta_bl_str,
6053 delta_bl_class: delta_bl_class.to_string(),
6054 delta_lines_added,
6055 delta_lines_removed,
6056 delta_lines_net_str,
6057 delta_lines_net_class,
6058 }
6059}
6060
6061fn delta_unmodified_lines(scan_delta: &sloc_core::ScanComparison) -> u64 {
6063 scan_delta
6064 .file_deltas
6065 .iter()
6066 .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
6067 .map(|f| {
6068 #[allow(clippy::cast_sign_loss)]
6069 let n = f.current_code as u64;
6070 n
6071 })
6072 .sum()
6073}
6074
6075fn git_commit_url_for(run: &AnalysisRun) -> Option<String> {
6076 run.git_remote_url
6077 .as_deref()
6078 .zip(run.git_commit_long.as_deref())
6079 .and_then(|(remote, sha)| remote_to_commit_url(remote, sha))
6080}
6081
6082fn git_branch_url_for(run: &AnalysisRun) -> Option<String> {
6083 run.git_remote_url
6084 .as_deref()
6085 .zip(run.git_branch.as_deref())
6086 .and_then(|(remote, branch)| remote_to_branch_url(remote, branch))
6087}
6088
6089fn scan_performed_by(run: &AnalysisRun) -> String {
6090 run.environment.ci_name.clone().unwrap_or_else(|| {
6091 format!(
6092 "{} / {}",
6093 run.environment.initiator_username, run.environment.initiator_hostname
6094 )
6095 })
6096}
6097
6098fn build_lang_chart_json(run: &AnalysisRun) -> String {
6100 let mut langs: Vec<&sloc_core::LanguageSummary> = run.totals_by_language.iter().collect();
6101 langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
6102 let entries: Vec<String> = langs
6103 .into_iter()
6104 .take(12)
6105 .map(|l| {
6106 let name = json_escape(l.language.display_name());
6107 format!(
6108 r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
6109 name,
6110 l.code_lines,
6111 l.comment_lines,
6112 l.blank_lines,
6113 l.total_physical_lines,
6114 l.functions,
6115 l.classes,
6116 l.variables,
6117 l.imports,
6118 l.files,
6119 )
6120 })
6121 .collect();
6122 format!("[{}]", entries.join(","))
6123}
6124
6125fn build_scatter_chart_json(run: &AnalysisRun) -> String {
6127 let entries: Vec<String> = run
6128 .totals_by_language
6129 .iter()
6130 .map(|l| {
6131 let name = json_escape(l.language.display_name());
6132 format!(
6133 r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
6134 name, l.files, l.code_lines, l.total_physical_lines,
6135 )
6136 })
6137 .collect();
6138 format!("[{}]", entries.join(","))
6139}
6140
6141fn build_semantic_chart_json(run: &AnalysisRun) -> String {
6143 let entries: Vec<String> = run
6144 .totals_by_language
6145 .iter()
6146 .filter(|l| {
6147 l.functions > 0 || l.classes > 0 || l.variables > 0 || l.imports > 0 || l.test_count > 0
6148 })
6149 .map(|l| {
6150 let name = json_escape(l.language.display_name());
6151 format!(
6152 r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{},"tests":{}}}"#,
6153 name, l.functions, l.classes, l.variables, l.imports, l.test_count,
6154 )
6155 })
6156 .collect();
6157 format!("[{}]", entries.join(","))
6158}
6159
6160fn build_submodule_chart_json(run: &AnalysisRun) -> String {
6162 let entries: Vec<String> = run
6163 .submodule_summaries
6164 .iter()
6165 .map(|s| {
6166 let name = json_escape(&s.name);
6167 format!(
6168 r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
6169 name,
6170 s.code_lines,
6171 s.comment_lines,
6172 s.blank_lines,
6173 s.total_physical_lines,
6174 s.files_analyzed,
6175 )
6176 })
6177 .collect();
6178 format!("[{}]", entries.join(","))
6179}
6180
6181#[allow(clippy::cast_precision_loss)]
6183fn cov_pct_str(hit: u64, found: u64) -> String {
6184 if found > 0 {
6185 format!("{:.1}", hit as f64 / found as f64 * 100.0)
6186 } else {
6187 String::new()
6188 }
6189}
6190
6191fn cov_lines_summary_str(hit: u64, found: u64) -> String {
6193 if found > 0 {
6194 format!("{hit} / {found}")
6195 } else {
6196 String::new()
6197 }
6198}
6199
6200const fn cocomo_coefficients(mode: sloc_core::CocomoMode) -> (f64, f64, f64, f64) {
6201 use sloc_core::CocomoMode;
6202 match mode {
6203 CocomoMode::SemiDetached => (3.0, 1.12, 2.5, 0.35),
6204 CocomoMode::Embedded => (3.6, 1.20, 2.5, 0.32),
6205 CocomoMode::Organic => (2.4, 1.05, 2.5, 0.38),
6206 }
6207}
6208
6209const fn cocomo_mode_label(mode: sloc_core::CocomoMode) -> &'static str {
6210 use sloc_core::CocomoMode;
6211 match mode {
6212 CocomoMode::Organic => "Organic",
6213 CocomoMode::SemiDetached => "Semi-detached",
6214 CocomoMode::Embedded => "Embedded",
6215 }
6216}
6217
6218const fn cocomo_mode_tooltip(mode: sloc_core::CocomoMode) -> &'static str {
6219 use sloc_core::CocomoMode;
6220 match mode {
6221 CocomoMode::Organic => {
6222 "Organic: A small team working on a well-understood project in a familiar \
6223 environment with minimal external constraints. Suited for internal tools, \
6224 utilities, and projects with stable requirements. Effort = 2.4 \u{00D7} KSLOC^1.05."
6225 }
6226 CocomoMode::SemiDetached => {
6227 "Semi-detached: A mixed team with varying experience tackling a project with \
6228 moderate novelty and some rigid constraints. Typical for compilers, transaction \
6229 systems, and batch processors. Effort = 3.0 \u{00D7} KSLOC^1.12."
6230 }
6231 CocomoMode::Embedded => {
6232 "Embedded: Tight hardware, software, or operational constraints requiring \
6233 significant innovation and deep integration work. Typical for real-time control \
6234 systems and safety-critical software. Effort = 3.6 \u{00D7} KSLOC^1.20."
6235 }
6236 }
6237}
6238
6239struct CocomoFields {
6241 has_cocomo: bool,
6242 effort_str: String,
6243 duration_str: String,
6244 staff_str: String,
6245 ksloc_str: String,
6246 mode_label: String,
6247 mode_tooltip: String,
6248}
6249
6250#[allow(clippy::cast_precision_loss)]
6251fn recompute_cocomo(run: &AnalysisRun, mode_str: &str) -> CocomoFields {
6252 use sloc_core::CocomoMode;
6253 let mode = match mode_str {
6254 "semi_detached" => CocomoMode::SemiDetached,
6255 "embedded" => CocomoMode::Embedded,
6256 _ => CocomoMode::Organic,
6257 };
6258 let (a, b, c, d) = cocomo_coefficients(mode);
6259 let ksloc = run.summary_totals.code_lines as f64 / 1_000.0;
6260 let effort = a * ksloc.powf(b);
6261 let duration = c * effort.powf(d);
6262 let staff = if duration > 0.0 {
6263 effort / duration
6264 } else {
6265 0.0
6266 };
6267 let round2 = |x: f64| format!("{:.2}", (x * 100.0).round() / 100.0);
6268 let mode_label = cocomo_mode_label(mode).to_string();
6269 let mode_tooltip = cocomo_mode_tooltip(mode).to_string();
6270 if run.summary_totals.code_lines > 0 {
6271 CocomoFields {
6272 has_cocomo: true,
6273 effort_str: round2(effort),
6274 duration_str: round2(duration),
6275 staff_str: round2(staff),
6276 ksloc_str: round2(ksloc),
6277 mode_label,
6278 mode_tooltip,
6279 }
6280 } else {
6281 CocomoFields {
6282 has_cocomo: false,
6283 effort_str: String::new(),
6284 duration_str: String::new(),
6285 staff_str: String::new(),
6286 ksloc_str: String::new(),
6287 mode_label,
6288 mode_tooltip,
6289 }
6290 }
6291}
6292
6293#[allow(clippy::too_many_lines)]
6294#[allow(clippy::similar_names)] #[allow(clippy::cast_precision_loss)] fn render_result_page(
6297 run: &AnalysisRun,
6298 artifacts: &RunArtifacts,
6299 run_id: &str,
6300 csp_nonce: &str,
6301 confluence_configured: bool,
6302 server_mode: bool,
6303) -> Response {
6304 let ctx = &artifacts.result_context;
6305 let prev_entry = &ctx.prev_entry;
6306 let prev_scan_count = ctx.prev_scan_count;
6307 let project_path_owned = if ctx.project_path.is_empty() {
6311 run.input_roots.join(", ")
6312 } else {
6313 ctx.project_path.clone()
6314 };
6315 let project_path = &project_path_owned;
6316
6317 let scan_delta = prev_entry.as_ref().and_then(|prev| {
6318 prev.json_path
6319 .as_ref()
6320 .and_then(|p| read_json(p).ok())
6321 .map(|prev_run| compute_delta(&prev_run, run))
6322 });
6323
6324 let files_analyzed = run.per_file_records.len() as u64;
6325 let files_skipped = run.skipped_file_records.len() as u64;
6326 let totals = sum_lang_totals(run);
6327
6328 let DeltaFields {
6329 prev_fa_str,
6330 prev_fs_str,
6331 prev_pl_str,
6332 prev_cl_str,
6333 prev_cml_str,
6334 prev_bl_str,
6335 delta_fa_str,
6336 delta_fa_class,
6337 delta_fs_str,
6338 delta_fs_class,
6339 delta_pl_str,
6340 delta_pl_class,
6341 delta_cl_str,
6342 delta_cl_class,
6343 delta_cml_str,
6344 delta_cml_class,
6345 delta_bl_str,
6346 delta_bl_class,
6347 delta_lines_added,
6348 delta_lines_removed,
6349 delta_lines_net_str,
6350 delta_lines_net_class,
6351 } = compute_delta_fields(
6352 prev_entry.as_ref(),
6353 &totals,
6354 files_analyzed,
6355 files_skipped,
6356 scan_delta.as_ref(),
6357 );
6358
6359 let run_dir = artifacts.output_dir.clone();
6360 let git_branch = run.git_branch.clone();
6361 let git_commit = run.git_commit_short.clone();
6362 let git_commit_long = run.git_commit_long.clone();
6363 let git_author = run.git_commit_author.clone();
6364 let git_commit_url = git_commit_url_for(run);
6365 let git_branch_url = git_branch_url_for(run);
6366 let scan_performed_by = scan_performed_by(run);
6367 let scan_time_display = fmt_la_time_meta(run.tool.timestamp_utc);
6368 let os_display = format!(
6369 "{} / {}",
6370 run.environment.operating_system, run.environment.architecture
6371 );
6372 let test_count = run.summary_totals.test_count;
6373
6374 let cyclomatic_complexity = run.summary_totals.cyclomatic_complexity;
6376 let lsloc = run.summary_totals.lsloc;
6377 let uloc = run.uloc;
6378 let dryness_pct_str = run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}"));
6379 let duplicate_group_count = run.duplicate_groups.len();
6380
6381 let ctx = &artifacts.result_context;
6383 let CocomoFields {
6384 has_cocomo,
6385 effort_str: cocomo_effort_str,
6386 duration_str: cocomo_duration_str,
6387 staff_str: cocomo_staff_str,
6388 ksloc_str: cocomo_ksloc_str,
6389 mode_label: cocomo_mode_label,
6390 mode_tooltip: cocomo_mode_tooltip,
6391 } = recompute_cocomo(run, ctx.cocomo_mode.as_str());
6392 let complexity_alert = ctx.complexity_alert;
6393
6394 let template = ResultTemplate {
6395 version: env!("CARGO_PKG_VERSION"),
6396 report_title: run.effective_configuration.reporting.report_title.clone(),
6397 project_path: project_path.clone(),
6398 output_dir: display_path(&artifacts.output_dir),
6399 run_id: run_id.to_owned(),
6400 run_id_short: run_id
6401 .split('-')
6402 .next_back()
6403 .unwrap_or(run_id)
6404 .chars()
6405 .take(7)
6406 .collect(),
6407 files_analyzed,
6408 files_skipped,
6409 physical_lines: totals.physical_lines,
6410 code_lines: totals.code_lines,
6411 comment_lines: totals.comment_lines,
6412 blank_lines: totals.blank_lines,
6413 mixed_lines: totals.mixed_lines,
6414 functions: totals.functions,
6415 classes: totals.classes,
6416 variables: totals.variables,
6417 imports: totals.imports,
6418 html_url: artifacts
6419 .html_path
6420 .as_ref()
6421 .map(|_| format!("/runs/html/{run_id}")),
6422 pdf_url: artifacts
6423 .pdf_path
6424 .as_ref()
6425 .map(|_| format!("/runs/pdf/{run_id}")),
6426 json_url: artifacts
6427 .json_path
6428 .as_ref()
6429 .map(|_| format!("/runs/json/{run_id}")),
6430 html_download_url: artifacts
6431 .html_path
6432 .as_ref()
6433 .map(|_| format!("/runs/html/{run_id}?download=1")),
6434 pdf_download_url: artifacts
6435 .pdf_path
6436 .as_ref()
6437 .map(|_| format!("/runs/pdf/{run_id}?download=1")),
6438 json_download_url: artifacts
6439 .json_path
6440 .as_ref()
6441 .map(|_| format!("/runs/json/{run_id}?download=1")),
6442 html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
6443 json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
6444 prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
6445 prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
6446 prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
6447 prev_fa_str,
6448 prev_fs_str,
6449 prev_pl_str,
6450 prev_cl_str,
6451 prev_cml_str,
6452 prev_bl_str,
6453 delta_fa_str,
6454 delta_fa_class,
6455 delta_fs_str,
6456 delta_fs_class,
6457 delta_pl_str,
6458 delta_pl_class,
6459 delta_cl_str,
6460 delta_cl_class,
6461 delta_cml_str,
6462 delta_cml_class,
6463 delta_bl_str,
6464 delta_bl_class,
6465 delta_lines_added,
6466 delta_lines_removed,
6467 delta_lines_net_str,
6468 delta_lines_net_class,
6469 delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
6470 delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
6471 delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
6472 delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
6473 delta_files_total: scan_delta.as_ref().map(|d| d.files_total),
6474 delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
6475 git_branch,
6476 git_branch_url,
6477 git_commit,
6478 git_commit_long,
6479 git_author,
6480 git_commit_url,
6481 scan_performed_by,
6482 scan_time_display,
6483 scan_time_utc_ms: run.tool.timestamp_utc.timestamp_millis(),
6484 os_display,
6485 test_count,
6486 test_assertion_count: run.summary_totals.test_assertion_count,
6487 current_scan_number: prev_scan_count + 1,
6488 prev_scan_count,
6489 submodule_rows: run
6490 .submodule_summaries
6491 .iter()
6492 .map(|s| build_submodule_row(s, run, run_id, &run_dir))
6493 .collect(),
6494 pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
6495 scan_config_url: format!("/runs/scan-config/{run_id}"),
6496 lang_chart_json: build_lang_chart_json(run),
6497 scatter_chart_json: build_scatter_chart_json(run),
6498 semantic_chart_json: build_semantic_chart_json(run),
6499 submodule_chart_json: build_submodule_chart_json(run),
6500 has_submodule_data: !run.submodule_summaries.is_empty(),
6501 has_semantic_data: run
6502 .totals_by_language
6503 .iter()
6504 .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
6505 csp_nonce: csp_nonce.to_owned(),
6506 confluence_configured,
6507 server_mode,
6508 report_header_footer: run
6509 .effective_configuration
6510 .reporting
6511 .report_header_footer
6512 .clone(),
6513 is_offline: false,
6514 cyclomatic_complexity,
6515 lsloc,
6516 uloc,
6517 dryness_pct_str,
6518 duplicate_group_count,
6519 has_cocomo,
6520 cocomo_effort_str,
6521 cocomo_duration_str,
6522 cocomo_staff_str,
6523 cocomo_ksloc_str,
6524 cocomo_mode_label,
6525 cocomo_mode_tooltip,
6526 complexity_alert,
6527 has_coverage_data: run.summary_totals.coverage_lines_found > 0,
6528 cov_line_pct: cov_pct_str(
6529 run.summary_totals.coverage_lines_hit,
6530 run.summary_totals.coverage_lines_found,
6531 ),
6532 cov_fn_pct: cov_pct_str(
6533 run.summary_totals.coverage_functions_hit,
6534 run.summary_totals.coverage_functions_found,
6535 ),
6536 cov_branch_pct: cov_pct_str(
6537 run.summary_totals.coverage_branches_hit,
6538 run.summary_totals.coverage_branches_found,
6539 ),
6540 cov_lines_summary: cov_lines_summary_str(
6541 run.summary_totals.coverage_lines_hit,
6542 run.summary_totals.coverage_lines_found,
6543 ),
6544 };
6545
6546 Html(
6547 template
6548 .render()
6549 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
6550 )
6551 .into_response()
6552}
6553
6554fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
6555 let slug: String = report_title
6556 .chars()
6557 .map(|c| {
6558 if c.is_alphanumeric() || c == '-' {
6559 c.to_ascii_lowercase()
6560 } else {
6561 '_'
6562 }
6563 })
6564 .collect::<String>()
6565 .split('_')
6566 .filter(|s| !s.is_empty())
6567 .collect::<Vec<_>>()
6568 .join("_");
6569
6570 let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
6571
6572 if slug.is_empty() {
6573 format!("report_{short_id}.pdf")
6574 } else {
6575 format!("{slug}_{short_id}.pdf")
6576 }
6577}
6578
6579#[derive(Serialize)]
6580struct PdfStatusResponse {
6581 ready: bool,
6582}
6583
6584async fn pdf_status_handler(
6587 State(state): State<AppState>,
6588 AxumPath(run_id): AxumPath<String>,
6589) -> Response {
6590 let pdf_path = {
6591 let registry = state.artifacts.lock().await;
6592 registry.get(&run_id).and_then(|a| a.pdf_path.clone())
6593 };
6594 let pdf_path = if pdf_path.is_some() {
6595 pdf_path
6596 } else {
6597 let reg = state.registry.lock().await;
6598 reg.find_by_run_id(&run_id)
6599 .map(recover_artifacts_from_registry)
6600 .and_then(|a| a.pdf_path)
6601 };
6602 let ready = pdf_path.is_some_and(|p| p.exists());
6603 Json(PdfStatusResponse { ready }).into_response()
6604}
6605
6606async fn download_bundle_handler(
6612 State(state): State<AppState>,
6613 AxumPath(run_id): AxumPath<String>,
6614) -> Response {
6615 let output_dir = {
6617 let cache = state.artifacts.lock().await;
6618 cache.get(&run_id).map(|a| a.output_dir.clone())
6619 };
6620 let output_dir = if let Some(d) = output_dir {
6621 d
6622 } else {
6623 let reg = state.registry.lock().await;
6624 match reg.find_by_run_id(&run_id) {
6625 Some(entry) => recover_artifacts_from_registry(entry).output_dir,
6626 None => {
6627 return (
6628 StatusCode::NOT_FOUND,
6629 Json(serde_json::json!({"error": "Run not found"})),
6630 )
6631 .into_response();
6632 }
6633 }
6634 };
6635
6636 if !output_dir.exists() {
6637 return (
6638 StatusCode::NOT_FOUND,
6639 Json(serde_json::json!({"error": "Output directory no longer exists on disk"})),
6640 )
6641 .into_response();
6642 }
6643
6644 let run_id_clone = run_id.clone();
6646 let archive_result = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
6647 use flate2::{Compression, write::GzEncoder};
6648 let mut enc = GzEncoder::new(Vec::new(), Compression::default());
6649 {
6650 let mut tar = tar::Builder::new(&mut enc);
6651 tar.follow_symlinks(false);
6652 if let Ok(entries) = std::fs::read_dir(&output_dir) {
6655 for entry in entries.filter_map(Result::ok) {
6656 let p = entry.path();
6657 if p.is_file() {
6658 let name = p.file_name().unwrap_or_default().to_string_lossy();
6659 let archive_path = format!("{run_id_clone}/{name}");
6660 tar.append_path_with_name(&p, &archive_path)?;
6661 }
6662 }
6663 }
6664 tar.finish()?;
6665 }
6666 Ok(enc.finish()?)
6667 })
6668 .await;
6669
6670 match archive_result {
6671 Ok(Ok(bytes)) => {
6672 let filename = format!("oxide-sloc-{}.tar.gz", &run_id[..run_id.len().min(8)]);
6673 axum::response::Response::builder()
6674 .status(StatusCode::OK)
6675 .header("Content-Type", "application/gzip")
6676 .header(
6677 "Content-Disposition",
6678 format!("attachment; filename=\"{filename}\""),
6679 )
6680 .header("Content-Length", bytes.len().to_string())
6681 .body(axum::body::Body::from(bytes))
6682 .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
6683 }
6684 Ok(Err(e)) => (
6685 StatusCode::INTERNAL_SERVER_ERROR,
6686 Json(serde_json::json!({"error": format!("Archive build failed: {e}")})),
6687 )
6688 .into_response(),
6689 Err(e) => (
6690 StatusCode::INTERNAL_SERVER_ERROR,
6691 Json(serde_json::json!({"error": format!("Task panicked: {e}")})),
6692 )
6693 .into_response(),
6694 }
6695}
6696
6697async fn delete_run_handler(
6702 State(state): State<AppState>,
6703 AxumPath(run_id): AxumPath<String>,
6704) -> Response {
6705 let output_dir = {
6707 let mut cache = state.artifacts.lock().await;
6708 let dir = cache.get(&run_id).map(|a| a.output_dir.clone());
6709 cache.remove(&run_id);
6710 dir
6711 };
6712 let output_dir = if let Some(d) = output_dir {
6713 d
6714 } else {
6715 let reg = state.registry.lock().await;
6716 reg.find_by_run_id(&run_id)
6717 .map(|e| recover_artifacts_from_registry(e).output_dir)
6718 .unwrap_or_default()
6719 };
6720
6721 {
6723 let mut reg = state.registry.lock().await;
6724 reg.entries.retain(|e| e.run_id != run_id);
6725 let _ = reg.save(&state.registry_path);
6726 }
6727
6728 if output_dir.exists() {
6731 match tokio::fs::remove_dir_all(&output_dir).await {
6732 Ok(()) => {}
6733 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
6734 Err(e) => {
6735 return (
6736 StatusCode::INTERNAL_SERVER_ERROR,
6737 Json(serde_json::json!({"error": format!("Failed to delete files: {e}")})),
6738 )
6739 .into_response();
6740 }
6741 }
6742 }
6743
6744 StatusCode::NO_CONTENT.into_response()
6745}
6746
6747async fn cleanup_runs_handler(
6752 State(state): State<AppState>,
6753 Json(body): Json<serde_json::Value>,
6754) -> Response {
6755 let days = body
6756 .get("older_than_days")
6757 .and_then(serde_json::Value::as_u64)
6758 .unwrap_or(30)
6759 .max(1);
6760
6761 let cutoff = chrono::Utc::now() - chrono::Duration::days(days.cast_signed());
6762
6763 let expired: Vec<(String, PathBuf)> = {
6765 let reg = state.registry.lock().await;
6766 reg.entries
6767 .iter()
6768 .filter(|e| e.timestamp_utc < cutoff)
6769 .map(|e| {
6770 let arts = recover_artifacts_from_registry(e);
6771 (e.run_id.clone(), arts.output_dir)
6772 })
6773 .collect()
6774 };
6775
6776 let mut deleted = 0usize;
6777 for (run_id, output_dir) in &expired {
6778 state.artifacts.lock().await.remove(run_id);
6780 if output_dir.exists()
6782 && let Err(e) = tokio::fs::remove_dir_all(output_dir).await
6783 {
6784 eprintln!(
6785 "[oxide-sloc] cleanup: failed to remove {}: {e:#}",
6786 output_dir.display()
6787 );
6788 continue;
6789 }
6790 deleted += 1;
6791 }
6792
6793 let expired_ids: std::collections::HashSet<&str> =
6795 expired.iter().map(|(id, _)| id.as_str()).collect();
6796 {
6797 let mut reg = state.registry.lock().await;
6798 reg.entries
6799 .retain(|e| !expired_ids.contains(e.run_id.as_str()));
6800 let _ = reg.save(&state.registry_path);
6801 }
6802
6803 Json(serde_json::json!({ "deleted": deleted })).into_response()
6804}
6805
6806fn spawn_cleanup_policy_task(state: AppState) -> tokio::task::JoinHandle<()> {
6809 tokio::spawn(async move {
6810 loop {
6811 let interval_secs = {
6812 let store = state.cleanup_policy.lock().await;
6813 match &store.policy {
6814 Some(p) if p.enabled => u64::from(p.interval_hours.max(1)) * 3600,
6815 _ => break,
6816 }
6817 };
6818 tokio::time::sleep(Duration::from_secs(interval_secs)).await;
6819 let n = run_auto_cleanup(&state).await;
6820 tracing::info!("[cleanup-policy] scheduled pass: deleted {n} runs");
6821 }
6822 })
6823}
6824
6825fn collect_runs_to_delete(
6826 reg: &ScanRegistry,
6827 max_age_days: Option<u32>,
6828 max_run_count: Option<u32>,
6829) -> std::collections::HashSet<String> {
6830 let mut to_delete = std::collections::HashSet::new();
6831 if let Some(days) = max_age_days {
6832 let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(days));
6833 for e in ®.entries {
6834 if e.timestamp_utc < cutoff {
6835 to_delete.insert(e.run_id.clone());
6836 }
6837 }
6838 }
6839 if let Some(max_count) = max_run_count {
6840 for e in reg.entries.iter().skip(max_count as usize) {
6842 to_delete.insert(e.run_id.clone());
6843 }
6844 }
6845 to_delete
6846}
6847
6848async fn delete_run_artifacts(state: &AppState, run_id: &str) {
6849 let output_dir = {
6850 let mut cache = state.artifacts.lock().await;
6851 let d = cache.get(run_id).map(|a| a.output_dir.clone());
6852 cache.remove(run_id);
6853 d
6854 };
6855 let output_dir = if let Some(d) = output_dir {
6856 d
6857 } else {
6858 let reg = state.registry.lock().await;
6859 reg.find_by_run_id(run_id)
6860 .map(|e| recover_artifacts_from_registry(e).output_dir)
6861 .unwrap_or_default()
6862 };
6863 if output_dir.exists() {
6864 let _ = tokio::fs::remove_dir_all(&output_dir).await;
6865 }
6866}
6867
6868async fn run_auto_cleanup(state: &AppState) -> u32 {
6872 let (max_age_days, max_run_count) = {
6873 let store = state.cleanup_policy.lock().await;
6874 match &store.policy {
6875 Some(p) if p.enabled => (p.max_age_days, p.max_run_count),
6876 _ => return 0,
6877 }
6878 };
6879
6880 let to_delete = {
6881 let reg = state.registry.lock().await;
6882 collect_runs_to_delete(®, max_age_days, max_run_count)
6883 };
6884
6885 for run_id in &to_delete {
6886 delete_run_artifacts(state, run_id).await;
6887 }
6888
6889 if !to_delete.is_empty() {
6891 let mut reg = state.registry.lock().await;
6892 reg.entries.retain(|e| !to_delete.contains(&e.run_id));
6893 let _ = reg.save(&state.registry_path);
6894 }
6895
6896 let deleted = u32::try_from(to_delete.len()).unwrap_or(u32::MAX);
6897 {
6898 let mut store = state.cleanup_policy.lock().await;
6899 store.last_run_at = Some(chrono::Utc::now());
6900 store.last_run_deleted = Some(deleted);
6901 let _ = store.save(&state.cleanup_policy_path);
6902 }
6903 deleted
6904}
6905
6906async fn api_get_cleanup_policy(State(state): State<AppState>) -> Response {
6910 let store = state.cleanup_policy.lock().await;
6911 Json(serde_json::json!({
6912 "policy": store.policy,
6913 "last_run_at": store.last_run_at,
6914 "last_run_deleted": store.last_run_deleted,
6915 }))
6916 .into_response()
6917}
6918
6919async fn api_save_cleanup_policy(
6921 State(state): State<AppState>,
6922 Json(body): Json<CleanupPolicy>,
6923) -> Response {
6924 {
6926 let mut handle = state.cleanup_task_handle.lock().await;
6927 if let Some(h) = handle.take() {
6928 h.abort();
6929 }
6930 }
6931 {
6932 let mut store = state.cleanup_policy.lock().await;
6933 store.policy = Some(body.clone());
6934 if let Err(e) = store.save(&state.cleanup_policy_path) {
6935 return (
6936 StatusCode::INTERNAL_SERVER_ERROR,
6937 Json(serde_json::json!({"error": e.to_string()})),
6938 )
6939 .into_response();
6940 }
6941 }
6942 if body.enabled {
6943 let handle = spawn_cleanup_policy_task(state.clone());
6944 *state.cleanup_task_handle.lock().await = Some(handle);
6945 }
6946 StatusCode::NO_CONTENT.into_response()
6947}
6948
6949async fn api_run_cleanup_now(State(state): State<AppState>) -> Response {
6951 let deleted = run_auto_cleanup(&state).await;
6952 Json(serde_json::json!({ "deleted": deleted })).into_response()
6953}
6954
6955async fn api_delete_cleanup_policy(State(state): State<AppState>) -> Response {
6957 {
6958 let mut handle = state.cleanup_task_handle.lock().await;
6959 if let Some(h) = handle.take() {
6960 h.abort();
6961 }
6962 }
6963 {
6964 let mut store = state.cleanup_policy.lock().await;
6965 store.policy = None;
6966 let _ = store.save(&state.cleanup_policy_path);
6967 }
6968 StatusCode::NO_CONTENT.into_response()
6969}
6970
6971fn swap_inline_chart_js_for_static(html: String) -> String {
6977 let Some(head_end) = html.find("</head>") else {
6978 return html;
6979 };
6980 let Some(script_start) = html[..head_end].rfind("<script") else {
6981 return html;
6982 };
6983 let Some(close_offset) = html[script_start..].find("</script>") else {
6984 return html;
6985 };
6986 let block_end = script_start + close_offset + "</script>".len();
6987 format!(
6988 "{}<script src=\"/static/chart-report.js\"></script>{}",
6989 &html[..script_start],
6990 &html[block_end..]
6991 )
6992}
6993
6994fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
6996 let Some(start) = html.find("nonce=\"") else {
6998 return html
7002 .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
7003 .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
7004 };
7005 let value_start = start + 7; let Some(end_offset) = html[value_start..].find('"') else {
7007 return html.to_owned();
7008 };
7009 let old_nonce = &html[value_start..value_start + end_offset];
7010 html.replace(
7011 &format!("nonce=\"{old_nonce}\""),
7012 &format!("nonce=\"{new_nonce}\""),
7013 )
7014}
7015
7016fn serve_html_artifact(
7017 path: &Path,
7018 wants_download: bool,
7019 csp_nonce: &str,
7020 run_id: &str,
7021 server_mode: bool,
7022) -> Response {
7023 match fs::read_to_string(path) {
7024 Ok(raw) => {
7025 let content = patch_html_nonce(&raw, csp_nonce);
7027 if wants_download {
7028 (
7030 [
7031 (header::CONTENT_TYPE, "text/html; charset=utf-8"),
7032 (
7033 header::CONTENT_DISPOSITION,
7034 "attachment; filename=report.html",
7035 ),
7036 ],
7037 content,
7038 )
7039 .into_response()
7040 } else {
7041 Html(swap_inline_chart_js_for_static(content)).into_response()
7044 }
7045 }
7046 Err(err) if err.kind() == std::io::ErrorKind::NotFound && !run_id.is_empty() => {
7047 let filename = path.file_name().map_or_else(
7048 || "report.html".to_string(),
7049 |n| n.to_string_lossy().into_owned(),
7050 );
7051 let html = LocateFileTemplate {
7052 run_id: run_id.to_owned(),
7053 artifact_type: "html".to_string(),
7054 expected_filename: filename,
7055 server_mode,
7056 csp_nonce: csp_nonce.to_owned(),
7057 version: env!("CARGO_PKG_VERSION"),
7058 }
7059 .render()
7060 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7061 (StatusCode::NOT_FOUND, Html(html)).into_response()
7062 }
7063 Err(err) => {
7064 let filename = path.file_name().map_or_else(
7065 || "report.html".to_string(),
7066 |n| n.to_string_lossy().into_owned(),
7067 );
7068 let msg = format!("HTML report '{filename}' could not be read.\n\nError: {err}");
7069 let html = ErrorTemplate {
7070 message: msg,
7071 last_report_url: Some("/view-reports".to_string()),
7072 last_report_label: Some("View Reports".to_string()),
7073 run_id: None,
7074 error_code: Some(404),
7075 csp_nonce: csp_nonce.to_owned(),
7076 version: env!("CARGO_PKG_VERSION"),
7077 }
7078 .render()
7079 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7080 (StatusCode::NOT_FOUND, Html(html)).into_response()
7081 }
7082 }
7083}
7084
7085fn serve_pdf_artifact(
7087 path: &Path,
7088 report_title: &str,
7089 run_id: &str,
7090 wants_download: bool,
7091 csp_nonce: &str,
7092) -> Response {
7093 match fs::read(path) {
7094 Ok(bytes) => {
7095 let filename = build_pdf_filename(report_title, run_id);
7096 let disposition = if wants_download {
7097 format!("attachment; filename=\"{filename}\"")
7098 } else {
7099 format!("inline; filename=\"{filename}\"")
7100 };
7101 (
7102 [
7103 (header::CONTENT_TYPE, "application/pdf".to_string()),
7104 (header::CONTENT_DISPOSITION, disposition),
7105 ],
7106 bytes,
7107 )
7108 .into_response()
7109 }
7110 Err(err) => {
7111 let filename = path.file_name().map_or_else(
7112 || "report.pdf".to_string(),
7113 |n| n.to_string_lossy().into_owned(),
7114 );
7115 let msg = format!(
7116 "PDF report '{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 PDF 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: Some(run_id.to_owned()),
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 serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
7139 match fs::read(path) {
7140 Ok(bytes) => {
7141 if wants_download {
7142 (
7143 [
7144 (header::CONTENT_TYPE, "application/json; charset=utf-8"),
7145 (
7146 header::CONTENT_DISPOSITION,
7147 "attachment; filename=result.json",
7148 ),
7149 ],
7150 bytes,
7151 )
7152 .into_response()
7153 } else {
7154 (
7155 [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
7156 bytes,
7157 )
7158 .into_response()
7159 }
7160 }
7161 Err(err) => {
7162 let filename = path.file_name().map_or_else(
7163 || "result.json".to_string(),
7164 |n| n.to_string_lossy().into_owned(),
7165 );
7166 let msg = format!(
7167 "JSON result '{filename}' could not be read.\n\n\
7168 Error: {err}\n\n\
7169 If you moved or renamed the output folder, the stored path is now stale. \
7170 Use 'Open JSON folder' from the results page to browse the output directory."
7171 );
7172 let html = ErrorTemplate {
7173 message: msg,
7174 last_report_url: Some("/view-reports".to_string()),
7175 last_report_label: Some("View Reports".to_string()),
7176 run_id: None,
7177 error_code: Some(404),
7178 csp_nonce: csp_nonce.to_owned(),
7179 version: env!("CARGO_PKG_VERSION"),
7180 }
7181 .render()
7182 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7183 (StatusCode::NOT_FOUND, Html(html)).into_response()
7184 }
7185 }
7186}
7187
7188fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
7190 let output_dir = entry
7193 .html_path
7194 .as_ref()
7195 .or(entry.json_path.as_ref())
7196 .or(entry.pdf_path.as_ref())
7197 .or(entry.csv_path.as_ref())
7198 .or(entry.xlsx_path.as_ref())
7199 .and_then(|p| {
7200 let parent = p.parent()?;
7201 let parent_name = parent.file_name().and_then(|n| n.to_str()).unwrap_or("");
7202 if matches!(parent_name, "html" | "json" | "pdf" | "excel") {
7204 parent.parent().map(PathBuf::from)
7205 } else {
7206 Some(parent.to_path_buf())
7207 }
7208 })
7209 .unwrap_or_default();
7210 let pdf_path = entry.pdf_path.clone().or_else(|| {
7213 let candidate = output_dir.join("report.pdf");
7214 candidate.exists().then_some(candidate)
7215 });
7216 let scan_dir_for = |ext: &str| -> Option<PathBuf> {
7220 for dir in &[output_dir.join("excel"), output_dir.clone()] {
7222 if let Some(p) = fs::read_dir(dir).ok().and_then(|entries| {
7223 entries
7224 .filter_map(std::result::Result::ok)
7225 .find(|e| {
7226 let n = e.file_name();
7227 let n = n.to_string_lossy();
7228 n.starts_with("report_") && n.ends_with(ext)
7229 })
7230 .map(|e| e.path())
7231 }) {
7232 return Some(p);
7233 }
7234 }
7235 None
7236 };
7237
7238 let csv_path = entry.csv_path.clone().or_else(|| scan_dir_for(".csv"));
7239 let xlsx_path = entry.xlsx_path.clone().or_else(|| scan_dir_for(".xlsx"));
7240 RunArtifacts {
7241 output_dir: output_dir.clone(),
7242 html_path: entry.html_path.clone(),
7243 pdf_path,
7244 json_path: entry.json_path.clone(),
7245 csv_path,
7246 xlsx_path,
7247 scan_config_path: find_scan_config_in_dir(&output_dir),
7248 report_title: entry.project_label.clone(),
7249 result_context: RunResultContext::default(),
7250 }
7251}
7252
7253#[allow(clippy::result_large_err)] async fn resolve_artifact_set(
7255 state: &AppState,
7256 run_id: &str,
7257 csp_nonce: &str,
7258) -> Result<RunArtifacts, Response> {
7259 let cached = state.artifacts.lock().await.get(run_id).cloned();
7260 if let Some(a) = cached {
7261 return Ok(a);
7262 }
7263 let reg = state.registry.lock().await;
7264 if let Some(entry) = reg.find_by_run_id(run_id) {
7265 return Ok(recover_artifacts_from_registry(entry));
7266 }
7267 drop(reg);
7268 let short_id = &run_id[..run_id.len().min(8)];
7269 let hint = if matches!(
7270 run_id,
7271 "pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
7272 ) {
7273 format!(
7274 " The URL format appears to be reversed \u{2014} \
7275 the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
7276 Use the View Reports page to navigate to your scan."
7277 )
7278 } else {
7279 " The report may have been deleted or the report directory moved. \
7280 Use View Reports to browse your scan history."
7281 .to_string()
7282 };
7283 let error_html = ErrorTemplate {
7284 message: format!("Report not found. \"{short_id}\" is not a recognized run ID.{hint}"),
7285 last_report_url: Some("/view-reports".to_string()),
7286 last_report_label: Some("View Reports".to_string()),
7287 run_id: None,
7288 error_code: Some(404),
7289 csp_nonce: csp_nonce.to_owned(),
7290 version: env!("CARGO_PKG_VERSION"),
7291 }
7292 .render()
7293 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
7294 Err((StatusCode::NOT_FOUND, Html(error_html)).into_response())
7295}
7296
7297async fn resolve_or_queue_pdf(
7302 state: &AppState,
7303 pdf_path: Option<PathBuf>,
7304 json_path: Option<PathBuf>,
7305 output_dir: PathBuf,
7306 run_id: &str,
7307 report_title: &str,
7308 csp_nonce: &str,
7309) -> Result<PathBuf, Response> {
7310 if let Some(p) = pdf_path {
7311 return Ok(p);
7312 }
7313 let Some(json_src) = json_path.filter(|p| p.exists()) else {
7314 let msg = "PDF report was not generated for this run. \
7315 Re-run the analysis with PDF output enabled."
7316 .to_string();
7317 let html = ErrorTemplate {
7318 message: msg,
7319 last_report_url: Some(format!("/runs/html/{run_id}")),
7320 last_report_label: Some("View HTML Report".to_string()),
7321 run_id: Some(run_id.to_string()),
7322 error_code: Some(404),
7323 csp_nonce: csp_nonce.to_string(),
7324 version: env!("CARGO_PKG_VERSION"),
7325 }
7326 .render()
7327 .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
7328 return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
7329 };
7330 let pdf_filename = build_pdf_filename(report_title, run_id);
7331 let pdf_dest = output_dir.join(&pdf_filename);
7332 if !pdf_dest.exists() {
7333 {
7335 let mut map = state.artifacts.lock().await;
7336 if let Some(entry) = map.get_mut(run_id) {
7337 entry.pdf_path = Some(pdf_dest.clone());
7338 }
7339 }
7340 {
7341 let mut reg = state.registry.lock().await;
7342 if let Some(e) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
7343 e.pdf_path = Some(pdf_dest.clone());
7344 }
7345 let _ = reg.save(&state.registry_path);
7346 }
7347 spawn_native_pdf_background(
7348 json_src,
7349 pdf_dest.clone(),
7350 run_id.to_string(),
7351 state.artifacts.clone(),
7352 );
7353 }
7354 Ok(pdf_dest)
7355}
7356
7357fn pdf_generating_response(run_id: &str, csp_nonce: &str) -> Response {
7359 let html = format!(
7360 "<!doctype html><html lang=\"en\"><head>\
7361 <meta charset=utf-8>\
7362 <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
7363 <meta http-equiv=\"refresh\" content=\"5\">\
7364 <title>OxideSLOC | Generating PDF\u{2026}</title>\
7365 <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
7366 <style nonce=\"{csp_nonce}\">\
7367 :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
7368 --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
7369 --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
7370 body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
7371 --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
7372 *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
7373 font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
7374 background:var(--bg);color:var(--text);}}\
7375 .top-nav{{position:sticky;top:0;z-index:30;\
7376 background:linear-gradient(180deg,var(--nav),var(--nav-2));\
7377 border-bottom:1px solid rgba(255,255,255,0.12);\
7378 box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
7379 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
7380 min-height:56px;display:flex;align-items:center;gap:14px;}}\
7381 .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
7382 .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
7383 filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
7384 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
7385 .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
7386 .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
7387 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
7388 .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
7389 border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
7390 background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
7391 .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
7392 .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
7393 justify-content:center;min-height:38px;border-radius:999px;\
7394 border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
7395 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
7396 .theme-toggle .icon-sun{{display:none;}}\
7397 body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
7398 body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
7399 .page{{width:100%;max-width:1720px;margin:0 auto;padding:60px 24px;\
7400 display:flex;align-items:center;justify-content:center;\
7401 min-height:calc(100vh - 56px);}}\
7402 @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}\
7403 .panel{{background:var(--surface);border:1px solid var(--line);\
7404 border-radius:var(--radius);box-shadow:var(--shadow);\
7405 padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
7406 .spin-ring{{width:56px;height:56px;border-radius:50%;\
7407 border:5px solid var(--line);border-top-color:var(--oxide-2);\
7408 animation:spin 1s linear infinite;margin:0 auto 28px;}}\
7409 @keyframes spin{{to{{transform:rotate(360deg);}}}}\
7410 h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
7411 p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
7412 .back-link{{display:inline-flex;align-items:center;justify-content:center;\
7413 min-height:42px;padding:0 20px;border-radius:14px;\
7414 border:1px solid var(--line-strong);text-decoration:none;\
7415 color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
7416 .back-link:hover{{background:var(--line);}}\
7417 </style></head>\
7418 <body>\
7419 <div class=\"top-nav\"><div class=\"top-nav-inner\">\
7420 <a class=\"brand\" href=\"/\">\
7421 <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
7422 <div class=\"brand-copy\">\
7423 <div class=\"brand-title\">OxideSLOC</div>\
7424 <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
7425 </div>\
7426 </a>\
7427 <div class=\"nav-right\">\
7428 <a class=\"nav-pill\" href=\"/\">Home</a>\
7429 <a class=\"nav-pill\" href=\"/view-reports\">View Reports</a>\
7430 <a class=\"nav-pill\" href=\"/compare-scans\">Compare Scans</a>\
7431 <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
7432 <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>\
7433 <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
7434 <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>\
7435 </button>\
7436 </div>\
7437 </div></div>\
7438 <div class=\"page\"><div class=\"panel\">\
7439 <div class=\"spin-ring\"></div>\
7440 <h1>Generating PDF\u{2026}</h1>\
7441 <p>The PDF is being generated from the scan results.<br>\
7442 This page refreshes automatically \u{2014} usually a few seconds.</p>\
7443 <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
7444 </div></div>\
7445 <script nonce=\"{csp_nonce}\">\
7446 (function(){{\
7447 var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
7448 if(s===\"dark\")b.classList.add(\"dark-theme\");\
7449 var t=document.getElementById(\"theme-toggle\");\
7450 if(t)t.addEventListener(\"click\",function(){{\
7451 var d=b.classList.toggle(\"dark-theme\");\
7452 localStorage.setItem(k,d?\"dark\":\"light\");\
7453 }});\
7454 }})();\
7455 </script>\
7456 </body></html>"
7457 );
7458 Html(html).into_response()
7459}
7460
7461fn render_error_artifact_html(
7463 message: String,
7464 last_report_url: Option<String>,
7465 last_report_label: Option<String>,
7466 run_id: Option<String>,
7467 error_code: Option<u16>,
7468 csp_nonce: &str,
7469) -> String {
7470 ErrorTemplate {
7471 message,
7472 last_report_url,
7473 last_report_label,
7474 run_id,
7475 error_code,
7476 csp_nonce: csp_nonce.to_owned(),
7477 version: env!("CARGO_PKG_VERSION"),
7478 }
7479 .render()
7480 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string())
7481}
7482
7483fn serve_binary_download(path: &Path, content_type: &str, fallback_filename: &str) -> Response {
7485 fs::read(path).map_or_else(
7486 |_| StatusCode::NOT_FOUND.into_response(),
7487 |bytes| {
7488 let filename = path.file_name().map_or_else(
7489 || fallback_filename.to_string(),
7490 |n| n.to_string_lossy().into_owned(),
7491 );
7492 (
7493 [
7494 (header::CONTENT_TYPE, content_type.to_string()),
7495 (
7496 header::CONTENT_DISPOSITION,
7497 format!("attachment; filename=\"{filename}\""),
7498 ),
7499 ],
7500 bytes,
7501 )
7502 .into_response()
7503 },
7504 )
7505}
7506
7507fn serve_csv_arm(csv_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
7508 let Some(path) = csv_path else {
7509 let html = render_error_artifact_html(
7510 "CSV report was not generated for this run, or was not recorded in \
7511 the scan registry."
7512 .to_string(),
7513 Some(format!("/runs/html/{run_id}")),
7514 Some("View HTML Report".to_string()),
7515 Some(run_id.to_string()),
7516 Some(404),
7517 csp_nonce,
7518 );
7519 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7520 };
7521 serve_binary_download(&path, "text/csv; charset=utf-8", "report.csv")
7522}
7523
7524fn serve_xlsx_arm(xlsx_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
7525 let Some(path) = xlsx_path else {
7526 let html = render_error_artifact_html(
7527 "Excel report was not generated for this run, or was not recorded in \
7528 the scan registry."
7529 .to_string(),
7530 Some(format!("/runs/html/{run_id}")),
7531 Some("View HTML Report".to_string()),
7532 Some(run_id.to_string()),
7533 Some(404),
7534 csp_nonce,
7535 );
7536 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7537 };
7538 serve_binary_download(
7539 &path,
7540 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
7541 "report.xlsx",
7542 )
7543}
7544
7545fn serve_scan_config_arm(artifact_set: &RunArtifacts) -> Response {
7546 let path = artifact_set
7547 .scan_config_path
7548 .as_deref()
7549 .map(std::path::Path::to_path_buf)
7550 .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
7551 .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
7552 fs::read(&path).map_or_else(
7553 |_| StatusCode::NOT_FOUND.into_response(),
7554 |bytes| {
7555 (
7556 [
7557 (
7558 header::CONTENT_TYPE,
7559 "application/json; charset=utf-8".to_string(),
7560 ),
7561 (
7562 header::CONTENT_DISPOSITION,
7563 "attachment; filename=\"scan-config.json\"".to_string(),
7564 ),
7565 ],
7566 bytes,
7567 )
7568 .into_response()
7569 },
7570 )
7571}
7572
7573async fn serve_submodule_pdf_arm(
7578 artifact: &str,
7579 artifact_set: RunArtifacts,
7580 wants_download: bool,
7581 run_id: &str,
7582 csp_nonce: &str,
7583) -> Response {
7584 let base = artifact.trim_end_matches("_pdf");
7586 let sub_dir = artifact_set.output_dir.join("submodules");
7587 let pdf_path = sub_dir.join(format!("{base}.pdf"));
7588
7589 if !pdf_path.exists() {
7590 let derived_safe = base.trim_start_matches("sub_");
7592 let rebuilt = artifact_set.json_path.as_deref().and_then(|jp| {
7593 let parent_run = read_json(jp).ok()?;
7594 let sub = parent_run
7595 .submodule_summaries
7596 .iter()
7597 .find(|s| sanitize_project_label(&s.name) == derived_safe)?
7598 .clone();
7599 let parent_path = parent_run.input_roots.first().cloned().unwrap_or_default();
7600 Some((parent_run, sub, parent_path))
7601 });
7602
7603 if let Some((parent_run, sub, parent_path)) = rebuilt {
7604 let sub_run = build_sub_run(&parent_run, &sub, &parent_path);
7605 let pp = pdf_path.clone();
7606 let _ = tokio::task::spawn_blocking(move || write_pdf_from_run(&sub_run, &pp)).await;
7607 }
7608 }
7609
7610 if !pdf_path.exists() {
7611 let html = render_error_artifact_html(
7612 "Sub-report PDF could not be generated — re-run the scan with submodule breakdown \
7613 enabled."
7614 .to_string(),
7615 Some("/view-reports".to_string()),
7616 Some("View Reports".to_string()),
7617 Some(run_id.to_string()),
7618 Some(404),
7619 csp_nonce,
7620 );
7621 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7622 }
7623
7624 serve_pdf_artifact(
7625 &pdf_path,
7626 &artifact_set.report_title,
7627 run_id,
7628 wants_download,
7629 csp_nonce,
7630 )
7631}
7632
7633fn serve_submodule_arm(
7634 artifact: &str,
7635 artifact_set: &RunArtifacts,
7636 wants_download: bool,
7637 csp_nonce: &str,
7638 run_id: &str,
7639 server_mode: bool,
7640) -> Response {
7641 if artifact.len() > 128
7642 || !artifact
7643 .chars()
7644 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
7645 {
7646 return StatusCode::BAD_REQUEST.into_response();
7647 }
7648 let filename = format!("{artifact}.html");
7649 let new_layout = artifact_set.output_dir.join("submodules").join(&filename);
7651 let path = if new_layout.exists() {
7652 new_layout
7653 } else {
7654 artifact_set.output_dir.join(&filename)
7655 };
7656 if !path.exists() {
7657 let html = render_error_artifact_html(
7658 format!(
7659 "Sub-report '{artifact}' was not found in the run directory.\n\
7660 Re-run the analysis with 'Detect and separate git submodules' \
7661 and HTML output enabled."
7662 ),
7663 Some("/view-reports".to_string()),
7664 Some("View Reports".to_string()),
7665 Some(run_id.to_string()),
7666 Some(404),
7667 csp_nonce,
7668 );
7669 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7670 }
7671 serve_html_artifact(&path, wants_download, csp_nonce, run_id, server_mode)
7672}
7673
7674async fn serve_pdf_arm(
7675 state: &AppState,
7676 artifact_set: RunArtifacts,
7677 wants_download: bool,
7678 run_id: &str,
7679 csp_nonce: &str,
7680) -> Response {
7681 let report_title = artifact_set.report_title.clone();
7682 let had_pdf_in_registry = artifact_set.pdf_path.is_some();
7683 let stale_html_name = artifact_set
7684 .html_path
7685 .as_deref()
7686 .and_then(|p| p.file_name())
7687 .map(|n| n.to_string_lossy().into_owned());
7688 let path = match resolve_or_queue_pdf(
7689 state,
7690 artifact_set.pdf_path,
7691 artifact_set.json_path.clone(),
7692 artifact_set.output_dir.clone(),
7693 run_id,
7694 &report_title,
7695 csp_nonce,
7696 )
7697 .await
7698 {
7699 Ok(p) => p,
7700 Err(r) => return r,
7701 };
7702 if !path.exists() {
7703 if had_pdf_in_registry && let Some(expected_filename) = stale_html_name {
7707 let html = LocateFileTemplate {
7708 run_id: run_id.to_string(),
7709 artifact_type: "pdf".to_string(),
7710 expected_filename,
7711 server_mode: state.server_mode,
7712 csp_nonce: csp_nonce.to_string(),
7713 version: env!("CARGO_PKG_VERSION"),
7714 }
7715 .render()
7716 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
7717 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7718 }
7719 return pdf_generating_response(run_id, csp_nonce);
7720 }
7721 serve_pdf_artifact(&path, &report_title, run_id, wants_download, csp_nonce)
7722}
7723
7724async fn artifact_handler(
7725 State(state): State<AppState>,
7726 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7727 AxumPath((artifact, run_id)): AxumPath<(String, String)>,
7728 Query(query): Query<ArtifactQuery>,
7729) -> Response {
7730 let artifact_set = match resolve_artifact_set(&state, &run_id, &csp_nonce).await {
7731 Ok(a) => a,
7732 Err(r) => return r,
7733 };
7734
7735 let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
7736
7737 match artifact.as_str() {
7738 "html" => {
7739 let Some(path) = artifact_set.html_path else {
7740 return StatusCode::NOT_FOUND.into_response();
7741 };
7742 serve_html_artifact(
7743 &path,
7744 wants_download,
7745 &csp_nonce,
7746 &run_id,
7747 state.server_mode,
7748 )
7749 }
7750 "pdf" => serve_pdf_arm(&state, artifact_set, wants_download, &run_id, &csp_nonce).await,
7751 "json" => {
7752 let Some(path) = artifact_set.json_path else {
7753 let html = render_error_artifact_html(
7754 "JSON result was not generated for this run, or was not recorded in \
7755 the scan registry. Re-run the analysis with JSON output enabled."
7756 .to_string(),
7757 Some("/view-reports".to_string()),
7758 Some("View Reports".to_string()),
7759 Some(run_id.clone()),
7760 Some(404),
7761 &csp_nonce,
7762 );
7763 return (StatusCode::NOT_FOUND, Html(html)).into_response();
7764 };
7765 serve_json_artifact(&path, wants_download, &csp_nonce)
7766 }
7767 "csv" => serve_csv_arm(artifact_set.csv_path, &run_id, &csp_nonce),
7768 "xlsx" => serve_xlsx_arm(artifact_set.xlsx_path, &run_id, &csp_nonce),
7769 "scan-config" => serve_scan_config_arm(&artifact_set),
7770 _ if artifact.starts_with("sub_") && artifact.ends_with("_pdf") => {
7771 serve_submodule_pdf_arm(&artifact, artifact_set, wants_download, &run_id, &csp_nonce)
7772 .await
7773 }
7774 _ if artifact.starts_with("sub_") => serve_submodule_arm(
7775 &artifact,
7776 &artifact_set,
7777 wants_download,
7778 &csp_nonce,
7779 &run_id,
7780 state.server_mode,
7781 ),
7782 _ => StatusCode::NOT_FOUND.into_response(),
7783 }
7784}
7785
7786struct SubmoduleLinkRow {
7789 name: String,
7790 url: String,
7791}
7792
7793struct HistoryEntryRow {
7794 run_id: String,
7795 run_id_short: String,
7796 timestamp: String,
7797 timestamp_utc_ms: i64,
7798 project_label: String,
7799 project_path: String,
7800 files_analyzed: u64,
7801 files_skipped: u64,
7802 code_lines: u64,
7803 comment_lines: u64,
7804 blank_lines: u64,
7805 total_physical_lines: u64,
7806 functions: u64,
7807 classes: u64,
7808 variables: u64,
7809 imports: u64,
7810 test_count: u64,
7811 git_branch: String,
7812 git_commit: String,
7813 git_commit_long: String,
7815 has_html: bool,
7816 has_json: bool,
7817 has_pdf: bool,
7818 submodule_links: Vec<SubmoduleLinkRow>,
7819 submodule_names_csv: String,
7821}
7822
7823fn nth_weekday_of_month(
7825 year: i32,
7826 month: u32,
7827 weekday: chrono::Weekday,
7828 n: u32,
7829) -> chrono::NaiveDate {
7830 use chrono::Datelike;
7831 let mut count = 0u32;
7832 let mut day = 1u32;
7833 loop {
7834 let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
7835 if d.weekday() == weekday {
7836 count += 1;
7837 if count == n {
7838 return d;
7839 }
7840 }
7841 day += 1;
7842 }
7843}
7844
7845fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
7849 use chrono::{Datelike, TimeZone};
7850 let year = dt.year();
7851 let dst_start = chrono::Utc.from_utc_datetime(
7852 &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
7853 .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
7854 );
7855 let dst_end = chrono::Utc.from_utc_datetime(
7856 &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
7857 .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
7858 );
7859 dt >= dst_start && dt < dst_end
7860}
7861
7862fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
7863 if is_pacific_dst(dt) {
7864 dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
7865 .format("%Y-%m-%d %H:%M PDT")
7866 .to_string()
7867 } else {
7868 dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
7869 .format("%Y-%m-%d %H:%M PST")
7870 .to_string()
7871 }
7872}
7873
7874fn fmt_la_time_meta(dt: chrono::DateTime<chrono::Utc>) -> String {
7876 let (offset, tz) = if is_pacific_dst(dt) {
7877 (
7878 chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"),
7879 "PDT",
7880 )
7881 } else {
7882 (
7883 chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"),
7884 "PST",
7885 )
7886 };
7887 format!(
7888 "{} {tz}",
7889 dt.with_timezone(&offset).format("%Y-%m-%d %H:%M:%S")
7890 )
7891}
7892
7893fn fmt_git_date(iso: &str) -> Option<String> {
7894 chrono::DateTime::parse_from_rfc3339(iso)
7895 .ok()
7896 .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
7897}
7898
7899fn extract_long_commit_from_json(path: &Path, short: &str) -> Option<String> {
7908 use std::io::{Read, Seek, SeekFrom};
7909 const TAIL: u64 = 4 * 1024 * 1024; if short.is_empty() {
7911 return None;
7912 }
7913 let len = std::fs::metadata(path).ok()?.len();
7914 let start = len.saturating_sub(TAIL);
7915 let mut file = std::fs::File::open(path).ok()?;
7916 file.seek(SeekFrom::Start(start)).ok()?;
7917 let mut buf = Vec::new();
7918 file.read_to_end(&mut buf).ok()?;
7919 let text = String::from_utf8_lossy(&buf);
7920 let short_lower = short.to_ascii_lowercase();
7921 let key = "\"git_commit_long\"";
7922 let mut found: Option<String> = None;
7923 let mut cursor = 0usize;
7924 while let Some(idx) = text[cursor..].find(key) {
7925 let after_key = cursor + idx + key.len();
7926 cursor = after_key;
7927 let rest = &text[after_key..];
7928 let Some(colon) = rest.find(':') else { break };
7929 let value_region = rest[colon + 1..].trim_start();
7930 if let Some(open) = value_region.strip_prefix('"')
7932 && let Some(close) = open.find('"')
7933 {
7934 let val = &open[..close];
7935 if val.len() >= short.len() && val.to_ascii_lowercase().starts_with(&short_lower) {
7936 found = Some(val.to_string());
7937 }
7938 }
7939 }
7940 found
7941}
7942
7943fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
7944 reg.entries
7945 .iter()
7946 .map(|e| {
7947 let submodule_links = {
7948 let mut links: Vec<SubmoduleLinkRow> = vec![];
7949 let sub_dir = e
7950 .html_path
7951 .as_ref()
7952 .and_then(|p| p.parent())
7953 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
7954 if let Some(dir) = sub_dir
7955 && let Ok(rd) = std::fs::read_dir(dir)
7956 {
7957 for entry_res in rd.flatten() {
7958 let fname = entry_res.file_name();
7959 let fname_str = fname.to_string_lossy();
7960 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
7961 let stem = &fname_str[..fname_str.len() - 5];
7962 let display = stem[4..].replace('-', " ");
7963 links.push(SubmoduleLinkRow {
7964 name: display,
7965 url: format!("/runs/{stem}/{}", e.run_id),
7966 });
7967 }
7968 }
7969 }
7970 links.sort_by(|a, b| a.name.cmp(&b.name));
7971 links
7972 };
7973 let submodule_names_csv = submodule_links
7974 .iter()
7975 .map(|l| l.name.as_str())
7976 .collect::<Vec<_>>()
7977 .join(",");
7978 HistoryEntryRow {
7979 run_id: e.run_id.clone(),
7980 run_id_short: e
7981 .run_id
7982 .split('-')
7983 .next_back()
7984 .unwrap_or(&e.run_id)
7985 .chars()
7986 .take(7)
7987 .collect(),
7988 timestamp: fmt_la_time(e.timestamp_utc),
7989 timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
7990 project_label: e.project_label.clone(),
7991 project_path: e
7992 .input_roots
7993 .first()
7994 .map(|s| sanitize_path_str(s))
7995 .unwrap_or_default(),
7996 files_analyzed: e.summary.files_analyzed,
7997 files_skipped: e.summary.files_skipped,
7998 code_lines: e.summary.code_lines,
7999 comment_lines: e.summary.comment_lines,
8000 blank_lines: e.summary.blank_lines,
8001 total_physical_lines: e.summary.total_physical_lines,
8002 functions: e.summary.functions,
8003 classes: e.summary.classes,
8004 variables: e.summary.variables,
8005 imports: e.summary.imports,
8006 test_count: e.summary.test_count,
8007 git_branch: e.git_branch.clone().unwrap_or_default(),
8008 git_commit: e.git_commit.clone().unwrap_or_default(),
8009 git_commit_long: {
8010 let short = e.git_commit.clone().unwrap_or_default();
8011 e.git_commit_long
8012 .clone()
8013 .filter(|s| !s.is_empty())
8014 .or_else(|| {
8015 e.json_path
8016 .as_ref()
8017 .and_then(|p| extract_long_commit_from_json(p, &short))
8018 })
8019 .unwrap_or(short)
8020 },
8021 has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
8022 has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
8023 has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
8024 submodule_links,
8025 submodule_names_csv,
8026 }
8027 })
8028 .collect()
8029}
8030
8031#[derive(Deserialize, Default)]
8032struct HistoryQuery {
8033 linked: Option<String>,
8034 error: Option<String>,
8035}
8036
8037async fn history_handler(
8038 State(state): State<AppState>,
8039 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8040 Query(query): Query<HistoryQuery>,
8041) -> impl IntoResponse {
8042 auto_scan_watched_dirs(&state).await;
8044 let watched_dirs: Vec<String> = {
8045 let wd = state.watched_dirs.lock().await;
8046 wd.dirs.iter().map(|p| p.display().to_string()).collect()
8047 };
8048 let mut entries = {
8049 let reg = state.registry.lock().await;
8050 make_history_rows(®)
8051 };
8052 entries.retain(|e| e.has_html);
8053 let total_scans = entries.len();
8054 let linked_count = query
8055 .linked
8056 .as_deref()
8057 .and_then(|s| s.parse::<usize>().ok())
8058 .unwrap_or(0);
8059 let browse_error = query.error.filter(|s| !s.is_empty());
8060 let template = HistoryTemplate {
8061 version: env!("CARGO_PKG_VERSION"),
8062 entries,
8063 total_scans,
8064 linked_count,
8065 browse_error,
8066 watched_dirs,
8067 csp_nonce,
8068 server_mode: state.server_mode,
8069 };
8070 Html(
8071 template
8072 .render()
8073 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
8074 )
8075 .into_response()
8076}
8077
8078async fn compare_select_handler(
8079 State(state): State<AppState>,
8080 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8081) -> impl IntoResponse {
8082 auto_scan_watched_dirs(&state).await;
8083 let watched_dirs: Vec<String> = {
8084 let wd = state.watched_dirs.lock().await;
8085 wd.dirs.iter().map(|p| p.display().to_string()).collect()
8086 };
8087 let mut entries = {
8088 let reg = state.registry.lock().await;
8089 make_history_rows(®)
8090 };
8091 entries.retain(|e| e.has_json);
8092 let total_scans = entries.len();
8093 let template = CompareSelectTemplate {
8094 version: env!("CARGO_PKG_VERSION"),
8095 entries,
8096 total_scans,
8097 watched_dirs,
8098 csp_nonce,
8099 server_mode: state.server_mode,
8100 };
8101 Html(
8102 template
8103 .render()
8104 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
8105 )
8106 .into_response()
8107}
8108
8109#[derive(Deserialize, Default)]
8112struct CompareQuery {
8113 a: Option<String>,
8114 b: Option<String>,
8115 sub: Option<String>,
8117 scope: Option<String>,
8119}
8120
8121struct CompareFileDeltaRow {
8122 relative_path: String,
8123 language: String,
8124 status: String,
8125 baseline_code: i64,
8126 current_code: i64,
8127 baseline_code_display: String,
8128 current_code_display: String,
8129 code_delta_str: String,
8130 code_delta_class: String,
8131 comment_delta_str: String,
8132 comment_delta_class: String,
8133 total_delta_str: String,
8134 total_delta_class: String,
8135}
8136
8137fn recompute_summary_from_records(run: &mut AnalysisRun) {
8140 let mut totals = SummaryTotals::default();
8141 for r in &run.per_file_records {
8142 if r.language.is_some() {
8143 totals.files_analyzed += 1;
8144 }
8145 totals.total_physical_lines += r.raw_line_categories.total_physical_lines;
8146 totals.code_lines += r.effective_counts.code_lines;
8147 totals.comment_lines += r.effective_counts.comment_lines;
8148 totals.blank_lines += r.effective_counts.blank_lines;
8149 totals.mixed_lines_separate += r.effective_counts.mixed_lines_separate;
8150 totals.functions += r.raw_line_categories.functions;
8151 totals.classes += r.raw_line_categories.classes;
8152 totals.variables += r.raw_line_categories.variables;
8153 totals.imports += r.raw_line_categories.imports;
8154 totals.test_count += r.raw_line_categories.test_count;
8155 totals.test_assertion_count += r.raw_line_categories.test_assertion_count;
8156 totals.test_suite_count += r.raw_line_categories.test_suite_count;
8157 if let Some(cov) = &r.coverage {
8158 totals.coverage_lines_found += u64::from(cov.lines_found);
8159 totals.coverage_lines_hit += u64::from(cov.lines_hit);
8160 totals.coverage_functions_found += u64::from(cov.functions_found);
8161 totals.coverage_functions_hit += u64::from(cov.functions_hit);
8162 totals.coverage_branches_found += u64::from(cov.branches_found);
8163 totals.coverage_branches_hit += u64::from(cov.branches_hit);
8164 }
8165 }
8166 totals.files_considered = totals.files_analyzed;
8167 run.summary_totals = totals;
8168}
8169
8170fn fmt_delta(n: i64) -> String {
8171 if n > 0 {
8172 format!("+{n}")
8173 } else {
8174 format!("{n}")
8175 }
8176}
8177
8178fn delta_class(n: i64) -> &'static str {
8179 use std::cmp::Ordering;
8180 match n.cmp(&0) {
8181 Ordering::Greater => "pos",
8182 Ordering::Less => "neg",
8183 Ordering::Equal => "zero",
8184 }
8185}
8186
8187#[allow(clippy::cast_precision_loss)]
8189fn fmt_pct(delta: i64, baseline: u64) -> String {
8190 if baseline == 0 {
8191 return "—".to_string();
8192 }
8193 #[allow(clippy::cast_precision_loss)]
8194 let pct = (delta as f64 / baseline as f64) * 100.0;
8195 if pct > 0.049 {
8196 format!("+{pct:.1}%")
8197 } else if pct < -0.049 {
8198 format!("{pct:.1}%")
8199 } else {
8200 "±0%".to_string()
8201 }
8202}
8203
8204fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
8206 prev.map_or_else(
8207 || ("—".to_string(), "na"),
8208 |p| {
8209 #[allow(clippy::cast_possible_wrap)]
8210 let d = curr as i64 - p as i64;
8211 (fmt_delta(d), delta_class(d))
8212 },
8213 )
8214}
8215
8216#[allow(clippy::result_large_err)] fn load_scan_for_compare(
8218 json_path: &std::path::Path,
8219 scan_label: &str,
8220 run_id: &str,
8221 server_mode: bool,
8222 compare_url: &str,
8223 csp_nonce: &str,
8224) -> Result<sloc_core::AnalysisRun, axum::response::Response> {
8225 match read_json(json_path) {
8226 Ok(r) => Ok(r),
8227 Err(e) => {
8228 if server_mode {
8229 let html = ErrorTemplate {
8230 message: format!(
8231 "Could not load {scan_label} scan data. The scan output folder may have \
8232 been moved, renamed, or deleted. Re-running the analysis will create \
8233 fresh comparison data."
8234 ),
8235 last_report_url: Some("/compare-scans".to_string()),
8236 last_report_label: Some("Compare Scans".to_string()),
8237 run_id: Some(run_id.to_owned()),
8238 error_code: Some(404),
8239 csp_nonce: csp_nonce.to_owned(),
8240 version: env!("CARGO_PKG_VERSION"),
8241 }
8242 .render()
8243 .unwrap_or_else(|_| format!("<pre>{scan_label} load failed.</pre>"));
8244 return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
8245 }
8246 let msg = format!(
8247 "Could not load {scan_label} scan data.\n\nExpected path: {}\n\nError: {e}",
8248 json_path.display()
8249 );
8250 let folder_hint = output_folder_hint(json_path);
8251 Err(missing_scan_relocate_response(
8252 &msg,
8253 run_id,
8254 &folder_hint,
8255 compare_url,
8256 false,
8257 csp_nonce,
8258 ))
8259 }
8260 }
8261}
8262
8263struct ChurnStats {
8264 new_scope: bool,
8265 scope_flag: bool,
8266 churn_rate_str: String,
8267 churn_rate_class: String,
8268}
8269
8270fn compute_churn_stats(
8271 baseline_code: u64,
8272 current_code: u64,
8273 lines_added: i64,
8274 lines_removed: i64,
8275) -> ChurnStats {
8276 let new_scope = baseline_code == 0 && current_code > 0;
8277 #[allow(clippy::cast_precision_loss)]
8278 let churn_pct = if baseline_code > 0 {
8279 (lines_added + lines_removed) as f64 / baseline_code as f64 * 100.0
8280 } else {
8281 0.0
8282 };
8283 #[allow(clippy::cast_precision_loss)]
8284 let scope_flag =
8285 new_scope || (baseline_code > 0 && lines_added as f64 / baseline_code as f64 > 0.20);
8286 let churn_rate_str = if new_scope {
8287 "New".to_string()
8288 } else if baseline_code > 0 {
8289 format!("{churn_pct:.1}%")
8290 } else {
8291 "—".to_string()
8292 };
8293 let churn_rate_class = if new_scope || churn_pct > 20.0 {
8294 "high".to_string()
8295 } else if churn_pct > 5.0 {
8296 "med".to_string()
8297 } else {
8298 "low".to_string()
8299 };
8300 ChurnStats {
8301 new_scope,
8302 scope_flag,
8303 churn_rate_str,
8304 churn_rate_class,
8305 }
8306}
8307
8308fn build_coverage_delta_card(s: &sloc_core::SummaryDelta) -> String {
8312 let has_data = s.baseline_coverage_line_pct.is_some() || s.current_coverage_line_pct.is_some();
8313 if !has_data {
8314 return String::new();
8315 }
8316 let base_str = s
8317 .baseline_coverage_line_pct
8318 .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
8319 let curr_str = s
8320 .current_coverage_line_pct
8321 .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
8322 let (delta_str, cls) = match s.coverage_line_pct_delta {
8323 Some(d) if d > 0.0 => (format!("+{d:.1} pp"), "pos"),
8324 Some(d) if d < 0.0 => (format!("{d:.1} pp"), "neg"),
8325 Some(_) => ("\u{00b1}0.0 pp".into(), "zero"),
8326 None => ("\u{2014}".into(), "zero"),
8327 };
8328 format!(
8329 r#"<div class="delta-card">
8330 <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>
8331 <div class="delta-card-label">Line coverage</div>
8332 <div class="delta-card-from">Before: {base_str}</div>
8333 <div class="delta-card-to">{curr_str}</div>
8334 <span class="delta-card-change {cls}">{delta_str}</span>
8335 </div>"#
8336 )
8337}
8338
8339#[allow(clippy::ref_option)]
8341fn narrow_run_pair_by_scope(
8342 mut baseline: AnalysisRun,
8343 mut current: AnalysisRun,
8344 active_sub: &Option<String>,
8345 super_scope: bool,
8346) -> (AnalysisRun, AnalysisRun) {
8347 if let Some(sub_name) = active_sub {
8348 baseline
8349 .per_file_records
8350 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
8351 current
8352 .per_file_records
8353 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
8354 recompute_summary_from_records(&mut baseline);
8355 recompute_summary_from_records(&mut current);
8356 } else if super_scope {
8357 baseline.per_file_records.retain(|f| f.submodule.is_none());
8358 current.per_file_records.retain(|f| f.submodule.is_none());
8359 recompute_summary_from_records(&mut baseline);
8360 recompute_summary_from_records(&mut current);
8361 }
8362 (baseline, current)
8363}
8364
8365#[allow(clippy::ref_option)]
8367fn apply_scope_filter(runs: &mut [AnalysisRun], active_sub: &Option<String>, super_scope: bool) {
8368 if let Some(sub_name) = active_sub {
8369 for run in runs.iter_mut() {
8370 run.per_file_records
8371 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
8372 recompute_summary_from_records(run);
8373 }
8374 } else if super_scope {
8375 for run in runs.iter_mut() {
8376 run.per_file_records.retain(|f| f.submodule.is_none());
8377 recompute_summary_from_records(run);
8378 }
8379 }
8380}
8381
8382#[allow(clippy::too_many_lines)]
8383async fn compare_handler(
8384 State(state): State<AppState>,
8385 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8386 Query(query): Query<CompareQuery>,
8387) -> impl IntoResponse {
8388 let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
8391 (Some(a), Some(b)) => (a.to_string(), b.to_string()),
8392 _ => return axum::response::Redirect::to("/compare-scans").into_response(),
8393 };
8394
8395 let (maybe_a, maybe_b) = {
8396 let reg = state.registry.lock().await;
8397 (
8398 reg.find_by_run_id(&run_id_a).cloned(),
8399 reg.find_by_run_id(&run_id_b).cloned(),
8400 )
8401 };
8402
8403 let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
8404 let html = ErrorTemplate {
8405 message: "One or both run IDs were not found in scan history. \
8406 The runs may have been deleted or the registry may have been reset."
8407 .to_string(),
8408 last_report_url: Some("/compare-scans".to_string()),
8409 last_report_label: Some("Compare Scans".to_string()),
8410 run_id: None,
8411 error_code: None,
8412 csp_nonce: csp_nonce.clone(),
8413 version: env!("CARGO_PKG_VERSION"),
8414 }
8415 .render()
8416 .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
8417 return Html(html).into_response();
8418 };
8419
8420 let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
8422 (entry_a, entry_b)
8423 } else {
8424 (entry_b, entry_a)
8425 };
8426
8427 if baseline_entry.run_id != run_id_a {
8431 let canonical = format!(
8432 "/compare?a={}&b={}",
8433 baseline_entry.run_id, current_entry.run_id
8434 );
8435 return axum::response::Redirect::to(&canonical).into_response();
8436 }
8437
8438 let (Some(base_json), Some(curr_json)) = (
8439 baseline_entry.json_path.as_ref(),
8440 current_entry.json_path.as_ref(),
8441 ) else {
8442 let html = ErrorTemplate {
8443 message: "Full comparison requires JSON scan data, which was not saved for one or \
8444 both of these runs. JSON is now always saved for new scans — re-run the \
8445 affected projects to enable comparisons."
8446 .to_string(),
8447 last_report_url: Some("/compare-scans".to_string()),
8448 last_report_label: Some("Compare Scans".to_string()),
8449 run_id: None,
8450 error_code: None,
8451 csp_nonce: csp_nonce.clone(),
8452 version: env!("CARGO_PKG_VERSION"),
8453 }
8454 .render()
8455 .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
8456 return Html(html).into_response();
8457 };
8458
8459 let compare_url = format!(
8460 "/compare?a={}&b={}",
8461 baseline_entry.run_id, current_entry.run_id
8462 );
8463
8464 let baseline_run = match load_scan_for_compare(
8465 base_json,
8466 "baseline",
8467 &baseline_entry.run_id,
8468 state.server_mode,
8469 &compare_url,
8470 &csp_nonce,
8471 ) {
8472 Ok(r) => r,
8473 Err(resp) => return resp,
8474 };
8475 let current_run = match load_scan_for_compare(
8476 curr_json,
8477 "current",
8478 ¤t_entry.run_id,
8479 state.server_mode,
8480 &compare_url,
8481 &csp_nonce,
8482 ) {
8483 Ok(r) => r,
8484 Err(resp) => return resp,
8485 };
8486
8487 let active_submodule = query.sub.clone();
8488 let super_scope_active = query.scope.as_deref() == Some("super");
8489
8490 let submodule_options = baseline_run
8491 .submodule_summaries
8492 .iter()
8493 .chain(current_run.submodule_summaries.iter())
8494 .map(|s| s.name.clone())
8495 .collect::<std::collections::BTreeSet<_>>()
8496 .into_iter()
8497 .collect::<Vec<_>>();
8498 let has_any_submodule_data = !submodule_options.is_empty();
8499
8500 let (effective_baseline, effective_current) = narrow_run_pair_by_scope(
8502 baseline_run,
8503 current_run,
8504 &active_submodule,
8505 super_scope_active,
8506 );
8507
8508 let comparison = compute_delta(&effective_baseline, &effective_current);
8509
8510 let file_rows: Vec<CompareFileDeltaRow> = comparison
8511 .file_deltas
8512 .iter()
8513 .map(|d| CompareFileDeltaRow {
8514 relative_path: d.relative_path.clone(),
8515 language: d.language.clone().unwrap_or_else(|| "—".into()),
8516 status: match d.status {
8517 FileChangeStatus::Added => "added".into(),
8518 FileChangeStatus::Removed => "removed".into(),
8519 FileChangeStatus::Modified => "modified".into(),
8520 FileChangeStatus::Unchanged => "unchanged".into(),
8521 },
8522 baseline_code: d.baseline_code,
8523 current_code: d.current_code,
8524 baseline_code_display: if d.status == FileChangeStatus::Added {
8525 "—".into()
8526 } else {
8527 d.baseline_code.to_string()
8528 },
8529 current_code_display: if d.status == FileChangeStatus::Removed {
8530 "—".into()
8531 } else {
8532 d.current_code.to_string()
8533 },
8534 code_delta_str: fmt_delta(d.code_delta),
8535 code_delta_class: delta_class(d.code_delta).into(),
8536 comment_delta_str: fmt_delta(d.comment_delta),
8537 comment_delta_class: delta_class(d.comment_delta).into(),
8538 total_delta_str: fmt_delta(d.total_delta),
8539 total_delta_class: delta_class(d.total_delta).into(),
8540 })
8541 .collect();
8542
8543 let project_path = baseline_entry
8544 .input_roots
8545 .first()
8546 .map(|s| sanitize_path_str(s))
8547 .unwrap_or_default();
8548 let lines_added = sum_added_code_lines(&comparison);
8549 let lines_removed = sum_removed_code_lines(&comparison);
8550 let churn = compute_churn_stats(
8551 comparison.summary.baseline_code,
8552 comparison.summary.current_code,
8553 lines_added,
8554 lines_removed,
8555 );
8556 let s = &comparison.summary;
8557 let template = CompareTemplate {
8558 loading_overlay: loading_overlay_block(&csp_nonce, "Loading scan delta"),
8559 version: env!("CARGO_PKG_VERSION"),
8560 project_label: baseline_entry.project_label.clone(),
8561 baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
8562 current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
8563 baseline_run_id: baseline_entry.run_id.clone(),
8564 current_run_id: current_entry.run_id.clone(),
8565 baseline_run_id_short: baseline_entry
8566 .run_id
8567 .split('-')
8568 .next_back()
8569 .unwrap_or(&baseline_entry.run_id)
8570 .chars()
8571 .take(7)
8572 .collect(),
8573 current_run_id_short: current_entry
8574 .run_id
8575 .split('-')
8576 .next_back()
8577 .unwrap_or(¤t_entry.run_id)
8578 .chars()
8579 .take(7)
8580 .collect(),
8581 baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
8582 baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
8583 current_timestamp: fmt_la_time(current_entry.timestamp_utc),
8584 current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
8585 project_path: project_path.clone(),
8586 baseline_code: s.baseline_code,
8587 current_code: s.current_code,
8588 code_lines_delta_str: fmt_delta(s.code_lines_delta),
8589 code_lines_delta_class: delta_class(s.code_lines_delta).into(),
8590 baseline_files: s.baseline_files,
8591 current_files: s.current_files,
8592 files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
8593 files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
8594 baseline_comments: s.baseline_comments,
8595 current_comments: s.current_comments,
8596 comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
8597 comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
8598 baseline_code_fmt: fmt_comma(s.baseline_code.cast_signed()),
8599 current_code_fmt: fmt_comma(s.current_code.cast_signed()),
8600 baseline_files_fmt: fmt_comma(s.baseline_files.cast_signed()),
8601 current_files_fmt: fmt_comma(s.current_files.cast_signed()),
8602 baseline_comments_fmt: fmt_comma(s.baseline_comments.cast_signed()),
8603 current_comments_fmt: fmt_comma(s.current_comments.cast_signed()),
8604 code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
8605 files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
8606 comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
8607 code_lines_added: lines_added,
8608 code_lines_removed: lines_removed,
8609 code_lines_modified: sum_modified_code_lines(&comparison),
8610 code_lines_unmodified: sum_unmodified_code_lines(&comparison),
8611 code_lines_total: lines_added
8612 + lines_removed
8613 + sum_modified_code_lines(&comparison)
8614 + sum_unmodified_code_lines(&comparison),
8615 new_scope: churn.new_scope,
8616 churn_rate_str: churn.churn_rate_str,
8617 churn_rate_class: churn.churn_rate_class,
8618 scope_flag: churn.scope_flag,
8619 files_added: comparison.files_added,
8620 files_removed: comparison.files_removed,
8621 files_modified: comparison.files_modified,
8622 files_unchanged: comparison.files_unchanged,
8623 files_total: comparison.files_total,
8624 file_rows,
8625 baseline_git_author: baseline_entry.git_author.clone(),
8626 current_git_author: current_entry.git_author.clone(),
8627 baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
8628 current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
8629 baseline_git_tags: baseline_entry.git_tags.clone(),
8630 current_git_tags: current_entry.git_tags.clone(),
8631 baseline_git_commit_date: baseline_entry
8632 .git_commit_date
8633 .as_deref()
8634 .and_then(fmt_git_date),
8635 current_git_commit_date: current_entry
8636 .git_commit_date
8637 .as_deref()
8638 .and_then(fmt_git_date),
8639 project_name: project_path
8640 .rsplit(['/', '\\'])
8641 .find(|s| !s.is_empty())
8642 .unwrap_or(&project_path)
8643 .to_string(),
8644 submodule_options,
8645 has_any_submodule_data,
8646 active_submodule,
8647 super_scope_active,
8648 toast_assets: sloc_toast_assets(&csp_nonce),
8649 csp_nonce,
8650 coverage_delta_card: build_coverage_delta_card(s),
8651 baseline_test_count: effective_baseline.summary_totals.test_count,
8652 current_test_count: effective_current.summary_totals.test_count,
8653 baseline_coverage_pct: s.baseline_coverage_line_pct,
8654 current_coverage_pct: s.current_coverage_line_pct,
8655 };
8656
8657 Html(
8658 template
8659 .render()
8660 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
8661 )
8662 .into_response()
8663}
8664
8665fn format_number(n: u64) -> String {
8673 let s = n.to_string();
8674 let mut out = String::with_capacity(s.len() + s.len() / 3);
8675 let len = s.len();
8676 for (i, c) in s.chars().enumerate() {
8677 if i > 0 && (len - i).is_multiple_of(3) {
8678 out.push(',');
8679 }
8680 out.push(c);
8681 }
8682 out
8683}
8684
8685const fn badge_char_width(c: char) -> f64 {
8686 match c {
8687 'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
8688 'm' | 'w' => 9.0,
8689 ' ' => 4.0,
8690 _ => 6.5,
8691 }
8692}
8693
8694#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
8695fn badge_text_px(text: &str) -> u32 {
8696 text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
8697}
8698
8699fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
8700 let lw = badge_text_px(label) + 20;
8701 let rw = badge_text_px(value) + 20;
8702 let total = lw + rw;
8703 let lx = lw / 2;
8704 let rx = lw + rw / 2;
8705 let le = escape_html(label);
8706 let ve = escape_html(value);
8707 let ce = escape_html(color);
8708 format!(
8709 r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
8710 <rect width="{total}" height="20" fill="#555"/>
8711 <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
8712 <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
8713 <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
8714 <text x="{lx}" y="13">{le}</text>
8715 <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
8716 <text x="{rx}" y="13">{ve}</text>
8717 </g>
8718</svg>"##
8719 )
8720}
8721
8722#[derive(Deserialize)]
8723struct BadgeQuery {
8724 label: Option<String>,
8725 color: Option<String>,
8726}
8727
8728async fn badge_handler(
8729 State(state): State<AppState>,
8730 AxumPath(metric): AxumPath<String>,
8731 Query(query): Query<BadgeQuery>,
8732) -> Response {
8733 let entry = {
8734 let reg = state.registry.lock().await;
8735 reg.entries.first().cloned()
8736 };
8737
8738 let Some(entry) = entry else {
8739 let svg = render_badge_svg("oxide-sloc", "no data", "#999");
8740 return (
8741 [
8742 (header::CONTENT_TYPE, "image/svg+xml"),
8743 (header::CACHE_CONTROL, "no-cache, max-age=0"),
8744 ],
8745 svg,
8746 )
8747 .into_response();
8748 };
8749
8750 let (default_label, value, default_color) = match metric.as_str() {
8751 "code-lines" => (
8752 "code lines",
8753 format_number(entry.summary.code_lines),
8754 "#4a78ee",
8755 ),
8756 "files" => (
8757 "files analyzed",
8758 format_number(entry.summary.files_analyzed),
8759 "#4a9862",
8760 ),
8761 "comment-lines" => (
8762 "comment lines",
8763 format_number(entry.summary.comment_lines),
8764 "#b35428",
8765 ),
8766 "blank-lines" => (
8767 "blank lines",
8768 format_number(entry.summary.blank_lines),
8769 "#7a5db0",
8770 ),
8771 _ => return StatusCode::NOT_FOUND.into_response(),
8772 };
8773
8774 let label = query.label.as_deref().unwrap_or(default_label);
8775 let color = query.color.as_deref().unwrap_or(default_color);
8776 let svg = render_badge_svg(label, &value, color);
8777
8778 (
8779 [
8780 (header::CONTENT_TYPE, "image/svg+xml"),
8781 (header::CACHE_CONTROL, "no-cache, max-age=0"),
8782 ],
8783 svg,
8784 )
8785 .into_response()
8786}
8787
8788#[derive(Serialize)]
8796struct ApiCoverageBlock {
8797 lines_found: u64,
8798 lines_hit: u64,
8799 line_pct: f64,
8800 functions_found: u64,
8801 functions_hit: u64,
8802 function_pct: f64,
8803 branches_found: u64,
8804 branches_hit: u64,
8805 branch_pct: f64,
8806}
8807
8808#[derive(Serialize)]
8809struct ApiMetricsResponse {
8810 run_id: String,
8811 timestamp: String,
8812 project: String,
8813 summary: ApiSummaryPayload,
8814 languages: Vec<ApiLanguageRow>,
8815 #[serde(skip_serializing_if = "Option::is_none")]
8816 coverage: Option<ApiCoverageBlock>,
8817}
8818
8819#[derive(Serialize)]
8820struct ApiSummaryPayload {
8821 files_analyzed: u64,
8822 files_skipped: u64,
8823 code_lines: u64,
8824 comment_lines: u64,
8825 blank_lines: u64,
8826 total_physical_lines: u64,
8827 functions: u64,
8828 classes: u64,
8829 variables: u64,
8830 imports: u64,
8831}
8832
8833#[derive(Serialize)]
8834struct ApiLanguageRow {
8835 name: String,
8836 files: u64,
8837 code_lines: u64,
8838 comment_lines: u64,
8839 blank_lines: u64,
8840 functions: u64,
8841 classes: u64,
8842 variables: u64,
8843 imports: u64,
8844}
8845
8846async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
8847 let entry = {
8848 let reg = state.registry.lock().await;
8849 reg.entries.first().cloned()
8850 };
8851 entry.map_or_else(
8852 || error::not_found("no scans recorded yet"),
8853 |e| build_metrics_response(&e),
8854 )
8855}
8856
8857async fn api_metrics_run_handler(
8858 State(state): State<AppState>,
8859 AxumPath(run_id): AxumPath<String>,
8860) -> Response {
8861 let entry = {
8862 let reg = state.registry.lock().await;
8863 reg.find_by_run_id(&run_id).cloned()
8864 };
8865 entry.map_or_else(
8866 || error::not_found("run not found"),
8867 |e| build_metrics_response(&e),
8868 )
8869}
8870
8871fn build_metrics_response(entry: &RegistryEntry) -> Response {
8872 let languages: Vec<ApiLanguageRow> = entry
8873 .json_path
8874 .as_ref()
8875 .and_then(|p| read_json(p).ok())
8876 .map(|run| {
8877 run.totals_by_language
8878 .iter()
8879 .map(|l| ApiLanguageRow {
8880 name: l.language.display_name().to_string(),
8881 files: l.files,
8882 code_lines: l.code_lines,
8883 comment_lines: l.comment_lines,
8884 blank_lines: l.blank_lines,
8885 functions: l.functions,
8886 classes: l.classes,
8887 variables: l.variables,
8888 imports: l.imports,
8889 })
8890 .collect()
8891 })
8892 .unwrap_or_default();
8893
8894 let s = &entry.summary;
8895 let coverage = if s.coverage_lines_found > 0 {
8896 let pct = |hit: u64, found: u64| -> f64 {
8897 if found == 0 {
8898 0.0
8899 } else {
8900 #[allow(clippy::cast_precision_loss)]
8901 let v = (hit as f64 / found as f64) * 100.0;
8902 (v * 10.0).round() / 10.0
8903 }
8904 };
8905 Some(ApiCoverageBlock {
8906 lines_found: s.coverage_lines_found,
8907 lines_hit: s.coverage_lines_hit,
8908 line_pct: pct(s.coverage_lines_hit, s.coverage_lines_found),
8909 functions_found: s.coverage_functions_found,
8910 functions_hit: s.coverage_functions_hit,
8911 function_pct: pct(s.coverage_functions_hit, s.coverage_functions_found),
8912 branches_found: s.coverage_branches_found,
8913 branches_hit: s.coverage_branches_hit,
8914 branch_pct: pct(s.coverage_branches_hit, s.coverage_branches_found),
8915 })
8916 } else {
8917 None
8918 };
8919 Json(ApiMetricsResponse {
8920 run_id: entry.run_id.clone(),
8921 timestamp: entry.timestamp_utc.to_rfc3339(),
8922 project: entry.project_label.clone(),
8923 summary: ApiSummaryPayload {
8924 files_analyzed: s.files_analyzed,
8925 files_skipped: s.files_skipped,
8926 code_lines: s.code_lines,
8927 comment_lines: s.comment_lines,
8928 blank_lines: s.blank_lines,
8929 total_physical_lines: s.total_physical_lines,
8930 functions: s.functions,
8931 classes: s.classes,
8932 variables: s.variables,
8933 imports: s.imports,
8934 },
8935 languages,
8936 coverage,
8937 })
8938 .into_response()
8939}
8940
8941#[derive(Deserialize)]
8948struct ProjectHistoryQuery {
8949 path: Option<String>,
8950}
8951
8952#[derive(Serialize)]
8953struct ProjectHistoryResponse {
8954 scan_count: usize,
8955 last_scan_id: Option<String>,
8956 last_scan_timestamp: Option<String>,
8957 last_scan_code_lines: Option<u64>,
8958 last_git_branch: Option<String>,
8959 last_git_commit: Option<String>,
8960}
8961
8962fn entry_matches_project(
8965 entry: &RegistryEntry,
8966 root_str: &str,
8967 upload_root: &str,
8968 upload_name_suffix: Option<&str>,
8969) -> bool {
8970 if entry.input_roots.iter().any(|r| r == root_str) {
8971 return true;
8972 }
8973 if let Some(suffix) = upload_name_suffix {
8974 return entry
8975 .input_roots
8976 .iter()
8977 .any(|r| r.starts_with(upload_root) && r.ends_with(suffix));
8978 }
8979 false
8980}
8981
8982async fn project_history_handler(
8983 State(state): State<AppState>,
8984 Query(query): Query<ProjectHistoryQuery>,
8985) -> Response {
8986 let path = query.path.unwrap_or_default();
8987 let resolved = resolve_input_path(&path);
8988 let root_str = resolved.to_string_lossy().replace('\\', "/");
8989
8990 let upload_root = std::env::temp_dir()
8995 .join("oxide-sloc-uploads")
8996 .to_string_lossy()
8997 .replace('\\', "/");
8998 let upload_name_suffix: Option<String> =
8999 if state.server_mode && root_str.starts_with(&upload_root) {
9000 resolved
9001 .file_name()
9002 .and_then(|n| n.to_str())
9003 .map(|name| format!("/{name}"))
9004 } else {
9005 None
9006 };
9007 let suffix_ref = upload_name_suffix.as_deref();
9008
9009 let entries: Vec<_> = {
9010 let reg = state.registry.lock().await;
9011 reg.entries
9012 .iter()
9013 .filter(|e| entry_matches_project(e, &root_str, &upload_root, suffix_ref))
9014 .cloned()
9015 .collect()
9016 };
9017 let scan_count = entries.len();
9018 let last = entries.first();
9019 let last_scan_id = last.map(|e| e.run_id.clone());
9020 let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
9021 let last_scan_code_lines = last.map(|e| e.summary.code_lines);
9022 let last_git_branch = last.and_then(|e| e.git_branch.clone());
9023 let last_git_commit = last.and_then(|e| e.git_commit.clone());
9024
9025 Json(ProjectHistoryResponse {
9026 scan_count,
9027 last_scan_id,
9028 last_scan_timestamp,
9029 last_scan_code_lines,
9030 last_git_branch,
9031 last_git_commit,
9032 })
9033 .into_response()
9034}
9035
9036#[derive(Deserialize)]
9043struct MetricsHistoryQuery {
9044 root: Option<String>,
9045 limit: Option<usize>,
9046 submodule: Option<String>,
9049}
9050
9051#[derive(Serialize)]
9052struct MetricsSubmoduleLink {
9053 name: String,
9054 url: String,
9055}
9056
9057#[derive(Serialize)]
9058struct MetricsHistoryEntry {
9059 run_id: String,
9060 run_id_short: String,
9061 timestamp: String,
9062 commit: Option<String>,
9063 branch: Option<String>,
9064 tags: Vec<String>,
9065 nearest_tag: Option<String>,
9066 code_lines: u64,
9067 comment_lines: u64,
9068 blank_lines: u64,
9069 physical_lines: u64,
9070 files_analyzed: u64,
9071 files_skipped: u64,
9072 test_count: u64,
9073 project_label: String,
9074 html_url: Option<String>,
9075 has_pdf: bool,
9076 submodule_links: Vec<MetricsSubmoduleLink>,
9077 #[serde(skip_serializing_if = "Option::is_none")]
9079 coverage_line_pct: Option<f64>,
9080}
9081
9082fn build_entry_submodule_links(e: &sloc_core::history::RegistryEntry) -> Vec<MetricsSubmoduleLink> {
9083 let mut links: Vec<MetricsSubmoduleLink> = vec![];
9084 let sub_dir = e
9085 .html_path
9086 .as_ref()
9087 .and_then(|p| p.parent())
9088 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
9089 let Some(dir) = sub_dir else { return links };
9090 let Ok(rd) = std::fs::read_dir(dir) else {
9091 return links;
9092 };
9093 for entry_res in rd.flatten() {
9094 let fname = entry_res.file_name();
9095 let fname_str = fname.to_string_lossy();
9096 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
9097 let stem = &fname_str[..fname_str.len() - 5];
9098 let display = stem[4..].replace('-', " ");
9099 links.push(MetricsSubmoduleLink {
9100 name: display,
9101 url: format!("/runs/{stem}/{}", e.run_id),
9102 });
9103 }
9104 }
9105 links.sort_by(|a, b| a.name.cmp(&b.name));
9106 links
9107}
9108
9109fn apply_submodule_filter(
9110 base: MetricsHistoryEntry,
9111 filter: &str,
9112 e: &sloc_core::history::RegistryEntry,
9113) -> Option<MetricsHistoryEntry> {
9114 let json_path = e.json_path.as_ref()?;
9115 let json_str = std::fs::read_to_string(json_path).ok()?;
9116 let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
9117 let sub = run
9118 .submodule_summaries
9119 .iter()
9120 .find(|s| s.name.to_lowercase() == filter || s.relative_path.to_lowercase() == filter)?;
9121 let safe = sanitize_project_label(&sub.name);
9122 let artifact_key = format!("sub_{safe}");
9123 let sub_html_url = std::path::Path::new(json_path).parent().map_or_else(
9124 || base.html_url.clone(),
9125 |run_dir| {
9126 let sub_path = run_dir.join(format!("{artifact_key}.html"));
9127 if sub_path.exists() {
9128 Some(format!("/runs/{artifact_key}/{}", e.run_id))
9129 } else {
9130 base.html_url.clone()
9131 }
9132 },
9133 );
9134
9135 let sub_files: Vec<_> = run
9138 .per_file_records
9139 .iter()
9140 .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
9141 .collect();
9142 let test_count: u64 = sub_files
9143 .iter()
9144 .map(|r| r.raw_line_categories.test_count)
9145 .sum();
9146 #[allow(clippy::cast_precision_loss)]
9147 let coverage_line_pct: Option<f64> = {
9148 let found: u64 = sub_files
9149 .iter()
9150 .filter_map(|r| r.coverage.as_ref())
9151 .map(|c| u64::from(c.lines_found))
9152 .sum();
9153 let hit: u64 = sub_files
9154 .iter()
9155 .filter_map(|r| r.coverage.as_ref())
9156 .map(|c| u64::from(c.lines_hit))
9157 .sum();
9158 if found > 0 {
9159 let pct = (hit as f64 / found as f64) * 100.0;
9160 Some((pct * 10.0).round() / 10.0)
9161 } else {
9162 None
9163 }
9164 };
9165
9166 Some(MetricsHistoryEntry {
9167 code_lines: sub.code_lines,
9168 comment_lines: sub.comment_lines,
9169 blank_lines: sub.blank_lines,
9170 physical_lines: sub.total_physical_lines,
9171 files_analyzed: sub.files_analyzed,
9172 files_skipped: 0,
9173 test_count,
9174 html_url: sub_html_url,
9175 has_pdf: false,
9176 submodule_links: vec![],
9177 coverage_line_pct,
9178 ..base
9179 })
9180}
9181
9182#[allow(clippy::too_many_lines)] async fn api_metrics_history_handler(
9184 State(state): State<AppState>,
9185 Query(query): Query<MetricsHistoryQuery>,
9186) -> Response {
9187 let limit = query.limit.unwrap_or(50).min(500);
9188 let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
9189
9190 let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
9191 let reg = state.registry.lock().await;
9192 reg.entries
9193 .iter()
9194 .filter(|e| {
9195 query.root.as_ref().is_none_or(|root| {
9196 let resolved = resolve_input_path(root);
9197 let root_str = resolved.to_string_lossy().replace('\\', "/");
9198 e.input_roots.iter().any(|r| r == &root_str)
9199 })
9200 })
9201 .take(limit)
9202 .cloned()
9203 .collect()
9204 };
9205
9206 let entries: Vec<MetricsHistoryEntry> = candidate_entries
9207 .into_iter()
9208 .filter_map(|e| {
9209 let tags = e
9210 .git_tags
9211 .as_deref()
9212 .map(|s| {
9213 s.split(',')
9214 .map(|t| t.trim().to_string())
9215 .filter(|t| !t.is_empty())
9216 .collect()
9217 })
9218 .unwrap_or_default();
9219 let html_url = e
9220 .html_path
9221 .as_ref()
9222 .filter(|p| p.exists())
9223 .map(|_| format!("/runs/html/{}", e.run_id));
9224 let nearest_tag = e.git_nearest_tag.clone();
9225 let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
9226 let run_id_short: String = e
9227 .run_id
9228 .split('-')
9229 .next_back()
9230 .unwrap_or(&e.run_id)
9231 .chars()
9232 .take(7)
9233 .collect();
9234 let submodule_links = build_entry_submodule_links(&e);
9235 #[allow(clippy::cast_precision_loss)]
9236 let coverage_line_pct = if e.summary.coverage_lines_found > 0 {
9237 let pct = (e.summary.coverage_lines_hit as f64
9238 / e.summary.coverage_lines_found as f64)
9239 * 100.0;
9240 Some((pct * 10.0).round() / 10.0)
9241 } else {
9242 None
9243 };
9244 let base = MetricsHistoryEntry {
9245 run_id: e.run_id.clone(),
9246 run_id_short,
9247 timestamp: e.timestamp_utc.to_rfc3339(),
9248 commit: e.git_commit.clone(),
9249 branch: e.git_branch.clone(),
9250 tags,
9251 nearest_tag,
9252 code_lines: e.summary.code_lines,
9253 comment_lines: e.summary.comment_lines,
9254 blank_lines: e.summary.blank_lines,
9255 physical_lines: e.summary.total_physical_lines,
9256 files_analyzed: e.summary.files_analyzed,
9257 files_skipped: e.summary.files_skipped,
9258 test_count: e.summary.test_count,
9259 project_label: e.project_label.clone(),
9260 html_url,
9261 has_pdf,
9262 submodule_links,
9263 coverage_line_pct,
9264 };
9265 if let Some(ref filter) = submodule_filter {
9266 apply_submodule_filter(base, filter, &e)
9267 } else {
9268 Some(base)
9269 }
9270 })
9271 .collect();
9272
9273 Json(entries).into_response()
9274}
9275
9276#[derive(Serialize)]
9278struct ChurnEntry {
9279 run_id: String,
9280 added: i64,
9281 removed: i64,
9282 modified: i64,
9283 unmodified: i64,
9284}
9285
9286async fn api_metrics_churn_handler(
9291 State(state): State<AppState>,
9292 Query(query): Query<MetricsHistoryQuery>,
9293) -> Response {
9294 let limit = query.limit.unwrap_or(200).min(500);
9295 let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
9296 let reg = state.registry.lock().await;
9297 reg.entries
9298 .iter()
9299 .filter(|e| {
9300 query.root.as_ref().is_none_or(|root| {
9301 let resolved = resolve_input_path(root);
9302 let root_str = resolved.to_string_lossy().replace('\\', "/");
9303 e.input_roots.iter().any(|r| r == &root_str)
9304 })
9305 })
9306 .take(limit)
9307 .cloned()
9308 .collect()
9309 };
9310 let mut by_project: std::collections::HashMap<String, Vec<sloc_core::history::RegistryEntry>> =
9311 std::collections::HashMap::new();
9312 for e in candidate_entries {
9313 by_project
9314 .entry(e.project_label.clone())
9315 .or_default()
9316 .push(e);
9317 }
9318 let mut out: Vec<ChurnEntry> = Vec::new();
9319 for (_proj, mut entries) in by_project {
9320 entries.sort_by_key(|e| e.timestamp_utc);
9321 let mut prev_run: Option<sloc_core::AnalysisRun> = None;
9322 for e in &entries {
9323 let curr = e
9324 .json_path
9325 .as_ref()
9326 .and_then(|path| sloc_core::read_json(path).ok());
9327 if let (Some(prev), Some(cur)) = (prev_run.as_ref(), curr.as_ref()) {
9328 let cmp = sloc_core::compute_delta(prev, cur);
9329 out.push(ChurnEntry {
9330 run_id: e.run_id.clone(),
9331 added: sum_added_code_lines(&cmp),
9332 removed: sum_removed_code_lines(&cmp),
9333 modified: sum_modified_code_lines(&cmp),
9334 unmodified: sum_unmodified_code_lines(&cmp),
9335 });
9336 } else {
9337 out.push(ChurnEntry {
9338 run_id: e.run_id.clone(),
9339 added: 0,
9340 removed: 0,
9341 modified: 0,
9342 unmodified: 0,
9343 });
9344 }
9345 if curr.is_some() {
9346 prev_run = curr;
9347 }
9348 }
9349 }
9350 Json(out).into_response()
9351}
9352
9353#[derive(Deserialize)]
9357struct MetricsSubmodulesQuery {
9358 root: Option<String>,
9359}
9360
9361#[derive(Serialize)]
9362struct SubmoduleEntry {
9363 name: String,
9364 relative_path: String,
9365}
9366
9367async fn api_metrics_submodules_handler(
9368 State(state): State<AppState>,
9369 Query(query): Query<MetricsSubmodulesQuery>,
9370) -> Response {
9371 let json_paths: Vec<std::path::PathBuf> = {
9372 let reg = state.registry.lock().await;
9373 reg.entries
9374 .iter()
9375 .filter(|e| {
9376 query.root.as_ref().is_none_or(|root| {
9377 let resolved = resolve_input_path(root);
9378 let root_str = resolved.to_string_lossy().replace('\\', "/");
9379 e.input_roots.iter().any(|r| r == &root_str)
9380 })
9381 })
9382 .filter_map(|e| e.json_path.clone())
9383 .collect()
9384 };
9385
9386 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
9387 let mut result: Vec<SubmoduleEntry> = Vec::new();
9388
9389 for path in &json_paths {
9390 let Ok(json_str) = tokio::fs::read_to_string(path).await else {
9391 continue;
9392 };
9393 let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
9394 continue;
9395 };
9396 for sub in &run.submodule_summaries {
9397 if seen.insert(sub.name.clone()) {
9398 result.push(SubmoduleEntry {
9399 name: sub.name.clone(),
9400 relative_path: sub.relative_path.clone(),
9401 });
9402 }
9403 }
9404 }
9405
9406 result.sort_by(|a, b| a.name.cmp(&b.name));
9407 Json(result).into_response()
9408}
9409
9410#[derive(Deserialize)]
9419struct IngestQuery {
9420 label: Option<String>,
9421}
9422
9423#[derive(Serialize)]
9424struct IngestResponse {
9425 run_id: String,
9426 view_url: String,
9427}
9428
9429async fn api_ingest_handler(
9430 State(state): State<AppState>,
9431 Query(q): Query<IngestQuery>,
9432 Json(run): Json<sloc_core::AnalysisRun>,
9433) -> Response {
9434 let label = q.label.unwrap_or_else(|| {
9435 run.input_roots
9436 .first()
9437 .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
9438 });
9439
9440 let label_for_task = label.clone();
9441 let result = tokio::task::spawn_blocking(move || {
9442 let html = render_html(&run)?;
9443 let run_id = run.tool.run_id.clone();
9444 let run_id_safe = run_id.len() <= 128
9445 && !run_id.is_empty()
9446 && run_id
9447 .chars()
9448 .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
9449 if !run_id_safe {
9450 anyhow::bail!(
9451 "invalid run_id: must be 1-128 alphanumeric/dash/underscore/dot characters"
9452 );
9453 }
9454 let project_label = sanitize_project_label(&label_for_task);
9455 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
9456 let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
9457 Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
9458 _ => project_label,
9459 };
9460 let (artifacts, _pending_pdf) = persist_run_artifacts(
9461 &run,
9462 &html,
9463 &output_dir,
9464 &label_for_task,
9465 &file_stem,
9466 RunResultContext::default(),
9467 )?;
9468 Ok::<_, anyhow::Error>((run_id, artifacts, run))
9469 })
9470 .await;
9471
9472 match result {
9473 Ok(Ok((run_id, artifacts, run))) => {
9474 register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
9475 (
9476 StatusCode::CREATED,
9477 Json(IngestResponse {
9478 view_url: format!("/view-reports?run_id={run_id}"),
9479 run_id,
9480 }),
9481 )
9482 .into_response()
9483 }
9484 Ok(Err(e)) => error::internal(&format!("{e:#}")),
9485 Err(e) => error::internal(&format!("{e}")),
9486 }
9487}
9488
9489fn html_escape(s: &str) -> String {
9493 s.replace('&', "&")
9494 .replace('<', "<")
9495 .replace('>', ">")
9496 .replace('"', """)
9497}
9498
9499#[allow(clippy::cast_precision_loss)]
9500fn fmt_num(n: i64) -> String {
9501 let a = n.unsigned_abs();
9502 if a >= 1_000_000 {
9503 let v = n as f64 / 1_000_000.0;
9504 let s = format!("{v:.1}");
9505 format!("{}M", s.trim_end_matches(".0"))
9506 } else if a >= 10_000 {
9507 let v = n as f64 / 1_000.0;
9508 let s = format!("{v:.1}");
9509 format!("{}K", s.trim_end_matches(".0"))
9510 } else {
9511 let sign = if n < 0 { "-" } else { "" };
9512 if a < 1_000 {
9513 return format!("{sign}{a}");
9514 }
9515 format!("{sign}{},{:03}", a / 1_000, a % 1_000)
9516 }
9517}
9518
9519fn fmt_comma(n: i64) -> String {
9520 let sign = if n < 0 { "-" } else { "" };
9521 let a = n.unsigned_abs();
9522 if a < 1_000 {
9523 return format!("{sign}{a}");
9524 }
9525 let s = a.to_string();
9526 let bytes = s.as_bytes();
9527 let len = bytes.len();
9528 let mut out = String::with_capacity(len + len / 3);
9529 for (i, &b) in bytes.iter().enumerate() {
9530 if i > 0 && (len - i).is_multiple_of(3) {
9531 out.push(',');
9532 }
9533 out.push(b as char);
9534 }
9535 format!("{sign}{out}")
9536}
9537
9538fn group_thousands(s: &str) -> String {
9545 let (sign, rest) = match s.as_bytes().first() {
9546 Some(b'-') => ("-", &s[1..]),
9547 Some(b'+') => ("+", &s[1..]),
9548 _ => ("", s),
9549 };
9550 let (int_part, frac_part) = match rest.split_once('.') {
9551 Some((i, f)) => (i, Some(f)),
9552 None => (rest, None),
9553 };
9554 if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
9555 return s.to_string();
9556 }
9557 let bytes = int_part.as_bytes();
9558 let len = bytes.len();
9559 let mut grouped = String::with_capacity(len + len / 3);
9560 for (i, &b) in bytes.iter().enumerate() {
9561 if i > 0 && (len - i).is_multiple_of(3) {
9562 grouped.push(',');
9563 }
9564 grouped.push(b as char);
9565 }
9566 frac_part.map_or_else(
9567 || format!("{sign}{grouped}"),
9568 |f| format!("{sign}{grouped}.{f}"),
9569 )
9570}
9571
9572mod filters {
9574 #![allow(clippy::inline_always, clippy::unused_self, clippy::unnecessary_wraps)]
9577 use askama::{Result, Values};
9578
9579 #[askama::filter_fn]
9584 pub fn commas<T: core::fmt::Display>(value: T, _: &dyn Values) -> Result<String> {
9585 Ok(super::group_thousands(&value.to_string()))
9586 }
9587}
9588
9589#[derive(Deserialize, Default)]
9590struct MultiCompareQuery {
9591 runs: Option<String>,
9592 scope: Option<String>,
9594 sub: Option<String>,
9596}
9597
9598#[allow(clippy::too_many_lines)]
9599async fn multi_compare_handler(
9600 State(state): State<AppState>,
9601 Query(params): Query<MultiCompareQuery>,
9602 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
9603) -> impl IntoResponse {
9604 let run_ids: Vec<String> = params
9605 .runs
9606 .as_deref()
9607 .unwrap_or("")
9608 .split(',')
9609 .map(|s| s.trim().to_string())
9610 .filter(|s| !s.is_empty())
9611 .collect();
9612
9613 if run_ids.len() < 2 {
9614 return Html(
9615 "<p style='font-family:sans-serif;padding:2rem'>At least 2 run IDs are required. \
9616 <a href=\"/compare-scans\">Go back</a></p>",
9617 )
9618 .into_response();
9619 }
9620 if run_ids.len() > 20 {
9621 return Html(
9622 "<p style='font-family:sans-serif;padding:2rem'>At most 20 scans can be compared \
9623 at once. <a href=\"/compare-scans\">Go back</a></p>",
9624 )
9625 .into_response();
9626 }
9627
9628 let entries: Vec<Option<RegistryEntry>> = {
9630 let reg = state.registry.lock().await;
9631 run_ids
9632 .iter()
9633 .map(|id| reg.entries.iter().find(|e| &e.run_id == id).cloned())
9634 .collect()
9635 };
9636
9637 for (i, entry) in entries.iter().enumerate() {
9638 if entry.is_none() {
9639 let html = format!(
9640 "<p style='font-family:sans-serif;padding:2rem'>Scan ID <code>{}</code> not \
9641 found. <a href=\"/compare-scans\">Go back</a></p>",
9642 run_ids[i]
9643 );
9644 return Html(html).into_response();
9645 }
9646 }
9647
9648 let mut entries: Vec<RegistryEntry> = entries.into_iter().flatten().collect();
9649
9650 for entry in &entries {
9651 if entry.json_path.is_none() {
9652 let html = format!(
9653 "<p style='font-family:sans-serif;padding:2rem'>Scan <code>{}</code> has no \
9654 JSON data — re-run the analysis to enable comparison. \
9655 <a href=\"/compare-scans\">Go back</a></p>",
9656 entry.run_id
9657 );
9658 return Html(html).into_response();
9659 }
9660 }
9661
9662 entries.sort_by_key(|e| e.timestamp_utc);
9664
9665 let mut runs: Vec<AnalysisRun> = Vec::with_capacity(entries.len());
9667 for entry in &entries {
9668 let path = entry.json_path.as_ref().unwrap();
9669 match read_json(path) {
9670 Ok(r) => runs.push(r),
9671 Err(e) => {
9672 let html = format!(
9673 "<p style='font-family:sans-serif;padding:2rem'>Could not load scan \
9674 <code>{}</code>: {e}. <a href=\"/compare-scans\">Go back</a></p>",
9675 entry.run_id
9676 );
9677 return Html(html).into_response();
9678 }
9679 }
9680 }
9681
9682 let all_sub_names: Vec<String> = {
9684 let mut set = std::collections::BTreeSet::new();
9685 for r in &runs {
9686 for s in &r.submodule_summaries {
9687 set.insert(s.name.clone());
9688 }
9689 }
9690 set.into_iter().collect()
9691 };
9692 let has_submodule_data = !all_sub_names.is_empty();
9693 let active_submodule = params.sub.clone();
9694 let super_scope_active = params.scope.as_deref() == Some("super");
9695
9696 apply_scope_filter(&mut runs, &active_submodule, super_scope_active);
9698
9699 let runs_csv = params.runs.as_deref().unwrap_or("").to_string();
9700 let project_label = entries
9701 .first()
9702 .map_or("", |e| e.project_label.as_str())
9703 .to_string();
9704 let run_refs: Vec<&AnalysisRun> = runs.iter().collect();
9705 let multi = compute_multi_delta(&run_refs);
9706 let html = multi_compare_page(
9707 &multi,
9708 &project_label,
9709 env!("CARGO_PKG_VERSION"),
9710 &csp_nonce,
9711 has_submodule_data,
9712 &all_sub_names,
9713 &runs_csv,
9714 super_scope_active,
9715 active_submodule.as_deref(),
9716 &entries,
9717 );
9718 (
9721 [(axum::http::header::CACHE_CONTROL, "no-store")],
9722 Html(html),
9723 )
9724 .into_response()
9725}
9726
9727const fn multi_delta_class(n: i64) -> &'static str {
9728 match n {
9729 1.. => "pos",
9730 ..=-1 => "neg",
9731 0 => "zero",
9732 }
9733}
9734
9735fn multi_fmt_delta(n: i64) -> String {
9736 if n > 0 {
9737 format!("+{n}")
9738 } else {
9739 format!("{n}")
9740 }
9741}
9742
9743fn js_escape(s: &str) -> String {
9745 use std::fmt::Write as _;
9746 let mut out = String::with_capacity(s.len() + 2);
9747 for c in s.chars() {
9748 match c {
9749 '"' => out.push_str("\\\""),
9750 '\\' => out.push_str("\\\\"),
9751 '\n' => out.push_str("\\n"),
9752 '\r' => out.push_str("\\r"),
9753 '\t' => out.push_str("\\t"),
9754 c if (c as u32) < 0x20 => {
9755 let _ = write!(out, "\\u{:04x}", c as u32);
9756 }
9757 c => out.push(c),
9758 }
9759 }
9760 out
9761}
9762
9763fn mc_entry_html_data(entries: &[RegistryEntry], idx: usize, run_id: &str) -> (String, String) {
9765 let Some(entry) = entries.get(idx).filter(|e| e.run_id == run_id) else {
9766 return (
9767 "—".to_string(),
9768 "<span class=\"mc-row-val\">—</span>".to_string(),
9769 );
9770 };
9771 let cd = entry
9772 .git_commit_date
9773 .as_deref()
9774 .and_then(fmt_git_date)
9775 .unwrap_or_else(|| "—".to_string());
9776 let au = entry.git_author.as_deref().map_or_else(
9777 || "<span class=\"mc-row-val\">—</span>".to_string(),
9778 |a| {
9779 format!(
9780 "<span class=\"mc-row-val\"><span class=\"cmp-author-val\">{}</span>\
9781 <span class=\"cmp-author-handle\"></span></span>",
9782 html_escape(a)
9783 )
9784 },
9785 );
9786 (cd, au)
9787}
9788
9789fn mc_scope_badge(active_sub: Option<&str>, super_scope_active: bool) -> String {
9791 active_sub.map_or_else(
9792 || {
9793 if super_scope_active {
9794 "<span class=\"mc-scope-tag mc-scope-super\">Super-repo only</span>".to_string()
9795 } else {
9796 "<span class=\"mc-scope-tag mc-scope-full\">\
9797 <svg width=\"9\" height=\"9\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\">\
9798 <circle cx=\"12\" cy=\"12\" r=\"10\"></circle>\
9799 <line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"></line>\
9800 <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>\
9801 </svg> Full scan</span>"
9802 .to_string()
9803 }
9804 },
9805 |s| format!("<span class=\"mc-scope-tag mc-scope-sub\">{}</span>", html_escape(s)),
9806 )
9807}
9808
9809fn build_mc_scan_strip(
9811 multi: &MultiScanComparison,
9812 entries: &[RegistryEntry],
9813 n: usize,
9814 is_many: bool,
9815 active_sub: Option<&str>,
9816 super_scope_active: bool,
9817 project_label: &str,
9818) -> String {
9819 use std::fmt::Write as _;
9820 let mut scan_strip = String::new();
9821 for (i, pt) in multi.points.iter().enumerate() {
9822 let ts_ms = pt.timestamp.timestamp_millis();
9823 let ts = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
9824 let commit = pt.git_commit.as_deref().unwrap_or("\u{2014}");
9825 let branch = pt.git_branch.as_deref().unwrap_or("");
9826 let report_link = format!("/runs/html/{}", pt.run_id);
9827 let branch_html = if branch.is_empty() {
9828 "<span class=\"mc-row-val\">—</span>".to_string()
9829 } else {
9830 format!(
9831 "<span class=\"mc-card-branch\">{}</span>",
9832 html_escape(branch)
9833 )
9834 };
9835 let (commit_date_html, author_html) = mc_entry_html_data(entries, i, &pt.run_id);
9836 let tags_html = pt
9837 .git_tags
9838 .as_deref()
9839 .filter(|t| !t.is_empty())
9840 .map(|t| {
9841 let chips = t
9842 .split(',')
9843 .filter(|s| !s.is_empty())
9844 .map(|tag| format!("<span class='mc-tag'>{}</span>", html_escape(tag)))
9845 .collect::<Vec<_>>()
9846 .join(" ");
9847 format!(
9848 "<div class=\"mc-card-row\"><span class=\"mc-row-label\">Tags:</span>\
9849 <span class=\"mc-row-val\">{chips}</span></div>"
9850 )
9851 })
9852 .unwrap_or_default();
9853 let nearest = pt
9854 .git_nearest_tag
9855 .as_deref()
9856 .map(|t| format!("near {}", html_escape(t)))
9857 .unwrap_or_default();
9858 let arrow = if i < n - 1 && !is_many {
9859 "<div class='mc-arrow'>→</div>"
9860 } else {
9861 ""
9862 };
9863 let scope_badge = mc_scope_badge(active_sub, super_scope_active);
9864 let nearest_html = if nearest.is_empty() {
9865 String::new()
9866 } else {
9867 format!(
9868 "<span class=\"mc-card-nearest-wrap\">\
9869 <span class=\"mc-card-nearest\">{nearest}</span>\
9870 <span class=\"mc-card-nearest-tip\">Nearest ancestor git release tag at scan time</span>\
9871 </span>"
9872 )
9873 };
9874 write!(
9875 scan_strip,
9876 r#"<div class="mc-card">
9877 <div class="mc-card-header">
9878 <div class="mc-card-num">Scan {num}</div>
9879 <div class="mc-card-project-col">
9880 <div class="mc-card-project">{project_label}</div>
9881 {scope_badge}
9882 </div>
9883 </div>
9884 <a class="mc-card-commit" href="{report_link}" target="_blank" title="View report">{commit}</a>
9885 <div class="mc-card-rows">
9886 <div class="mc-card-row"><span class="mc-row-label">Branch:</span>{branch_html}</div>
9887 <div class="mc-card-row"><span class="mc-row-label">Last commit on:</span><span class="mc-row-val">{commit_date}</span></div>
9888 <div class="mc-card-row"><span class="mc-row-label">Last commit by:</span>{author_html}</div>
9889 <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>
9890 {tags_html}
9891 </div>
9892 <div class="mc-card-code"><strong>{code} loc</strong>{nearest_html}</div>
9893 </div>{arrow}"#,
9894 num = i + 1,
9895 commit = html_escape(commit),
9896 commit_date = commit_date_html,
9897 ts_ms = ts_ms,
9898 code = fmt_num(pt.code_lines),
9899 scope_badge = scope_badge,
9900 nearest_html = nearest_html,
9901 )
9902 .unwrap();
9903 }
9904 scan_strip
9905}
9906
9907#[allow(clippy::too_many_lines)]
9909fn build_mc_metrics_table(multi: &MultiScanComparison, n: usize) -> (String, String) {
9910 use std::fmt::Write as _;
9911 struct MetricRow<'a> {
9912 label: &'a str,
9913 values: Vec<i64>,
9914 seq_deltas: Vec<i64>,
9915 net_delta: i64,
9916 }
9917 let rows: Vec<MetricRow<'_>> = vec![
9918 MetricRow {
9919 label: "Code Lines",
9920 values: multi.points.iter().map(|p| p.code_lines).collect(),
9921 seq_deltas: multi
9922 .sequential_deltas
9923 .iter()
9924 .map(|d| d.summary.code_lines_delta)
9925 .collect(),
9926 net_delta: multi.total_delta.code_lines_delta,
9927 },
9928 MetricRow {
9929 label: "Files Analyzed",
9930 values: multi.points.iter().map(|p| p.files_analyzed).collect(),
9931 seq_deltas: multi
9932 .sequential_deltas
9933 .iter()
9934 .map(|d| d.summary.files_analyzed_delta)
9935 .collect(),
9936 net_delta: multi.total_delta.files_analyzed_delta,
9937 },
9938 MetricRow {
9939 label: "Comment Lines",
9940 values: multi.points.iter().map(|p| p.comment_lines).collect(),
9941 seq_deltas: multi
9942 .sequential_deltas
9943 .iter()
9944 .map(|d| d.summary.comment_lines_delta)
9945 .collect(),
9946 net_delta: multi.total_delta.comment_lines_delta,
9947 },
9948 MetricRow {
9949 label: "Blank Lines",
9950 values: multi.points.iter().map(|p| p.blank_lines).collect(),
9951 seq_deltas: multi
9952 .sequential_deltas
9953 .iter()
9954 .map(|d| d.summary.blank_lines_delta)
9955 .collect(),
9956 net_delta: multi.total_delta.blank_lines_delta,
9957 },
9958 MetricRow {
9959 label: "Tests",
9960 values: multi.points.iter().map(|p| p.test_count).collect(),
9961 seq_deltas: multi
9962 .points
9963 .windows(2)
9964 .map(|pts| pts[1].test_count - pts[0].test_count)
9965 .collect(),
9966 net_delta: multi.points.last().map_or(0, |l| l.test_count)
9967 - multi.points.first().map_or(0, |f| f.test_count),
9968 },
9969 ];
9970 let mut metrics_thead = String::from("<tr><th class='mc-met-label'>Metric</th>");
9971 for i in 0..n {
9972 write!(metrics_thead, "<th class='mc-val-col'>Scan {}</th>", i + 1).unwrap();
9973 if i < n - 1 {
9974 metrics_thead.push_str("<th class='mc-delta-col'>→Δ</th>");
9975 }
9976 }
9977 metrics_thead.push_str("<th class='mc-net-col'>Net Δ</th></tr>");
9978 let mut metrics_tbody = String::new();
9979 for row in &rows {
9980 metrics_tbody.push_str("<tr>");
9981 write!(metrics_tbody, "<td class='mc-met-label'>{}</td>", row.label).unwrap();
9982 for i in 0..n {
9983 write!(
9984 metrics_tbody,
9985 "<td class='mc-val-col'>{}</td>",
9986 fmt_comma(row.values[i])
9987 )
9988 .unwrap();
9989 if i < n - 1 {
9990 let d = row.seq_deltas[i];
9991 write!(
9992 metrics_tbody,
9993 "<td class='mc-delta-col {cls}'>{val}</td>",
9994 cls = multi_delta_class(d),
9995 val = multi_fmt_delta(d)
9996 )
9997 .unwrap();
9998 }
9999 }
10000 let nd = row.net_delta;
10001 write!(
10002 metrics_tbody,
10003 "<td class='mc-net-col {cls}'>{val}</td>",
10004 cls = multi_delta_class(nd),
10005 val = multi_fmt_delta(nd)
10006 )
10007 .unwrap();
10008 metrics_tbody.push_str("</tr>");
10009 }
10010 (metrics_thead, metrics_tbody)
10011}
10012
10013fn build_mc_points_json(multi: &MultiScanComparison, entries: &[RegistryEntry]) -> String {
10015 let mut parts: Vec<String> = Vec::with_capacity(multi.points.len());
10016 for (i, pt) in multi.points.iter().enumerate() {
10017 let commit = pt.git_commit.as_deref().unwrap_or("");
10018 let branch = pt.git_branch.as_deref().unwrap_or("");
10019 let tags = pt.git_tags.as_deref().unwrap_or("");
10020 let nearest = pt.git_nearest_tag.as_deref().unwrap_or("");
10021 let scanned_ms = pt.timestamp.timestamp_millis();
10022 let scanned = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
10023 let entry = entries.get(i).filter(|e| e.run_id == pt.run_id);
10024 let commit_date = entry
10025 .and_then(|e| e.git_commit_date.as_deref())
10026 .and_then(fmt_git_date)
10027 .unwrap_or_default();
10028 let author = entry
10029 .and_then(|e| e.git_author.as_deref())
10030 .unwrap_or("")
10031 .to_string();
10032 let cov = pt
10033 .coverage_line_pct
10034 .map_or_else(|| "null".to_string(), |v| format!("{v:.1}"));
10035 parts.push(format!(
10036 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}}}"#,
10037 run_id = js_escape(&pt.run_id),
10038 commit = js_escape(commit),
10039 branch = js_escape(branch),
10040 tags = js_escape(tags),
10041 nearest = js_escape(nearest),
10042 commit_date = js_escape(&commit_date),
10043 author = js_escape(&author),
10044 scanned = js_escape(&scanned),
10045 code = pt.code_lines,
10046 comments = pt.comment_lines,
10047 blank = pt.blank_lines,
10048 files = pt.files_analyzed,
10049 tests = pt.test_count,
10050 ));
10051 }
10052 format!("[{}]", parts.join(","))
10053}
10054
10055fn build_mc_file_matrix_json(multi: &MultiScanComparison) -> String {
10057 let mut parts: Vec<String> = Vec::with_capacity(multi.file_matrix.len());
10058 for row in &multi.file_matrix {
10059 let lang = row.language.as_deref().unwrap_or("");
10060 let codes: Vec<String> = row
10061 .code_per_scan
10062 .iter()
10063 .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
10064 .collect();
10065 let deltas: Vec<String> = row
10066 .code_delta_per_scan
10067 .iter()
10068 .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
10069 .collect();
10070 parts.push(format!(
10071 r#"{{"p":"{path}","l":"{lang}","s":"{status}","c":[{codes}],"d":[{deltas}],"t":{total}}}"#,
10072 path = row.relative_path.replace('\\', "/").replace('"', "\\\""),
10073 status = row.overall_status,
10074 codes = codes.join(","),
10075 deltas = deltas.join(","),
10076 total = row.total_code_delta,
10077 ));
10078 }
10079 format!("[{}]", parts.join(","))
10080}
10081
10082fn build_mc_file_col_headers(n: usize) -> String {
10084 use std::fmt::Write as _;
10085 let mut out = String::new();
10086 for i in 0..n {
10087 write!(out, "<th class='file-scan-col'>Scan {} Code</th>", i + 1).unwrap();
10088 if i < n - 1 {
10089 write!(
10090 out,
10091 "<th class='file-delta-col'>Δ→{}</th>",
10092 i + 2
10093 )
10094 .unwrap();
10095 }
10096 }
10097 out
10098}
10099
10100fn build_mc_scope_bar(
10102 has_submodule_data: bool,
10103 sub_names: &[String],
10104 runs_csv: &str,
10105 active_sub: Option<&str>,
10106 super_scope_active: bool,
10107) -> String {
10108 use std::fmt::Write as _;
10109 if !has_submodule_data {
10110 return String::new();
10111 }
10112 let base_url = format!("/multi-compare?runs={}", html_escape(runs_csv));
10113 let full_active = active_sub.is_none() && !super_scope_active;
10114 let mut bar = format!(
10115 r#"<div class="submod-scope-bar">
10116 <span class="submod-scope-label">
10117 <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>
10118 Scope:
10119 </span>
10120 <div class="submod-scope-divider"></div>
10121 <a class="submod-scope-btn{full_cls}" href="{base_url}" title="All files — super-repo and all submodules combined">Full scan</a>
10122 <a class="submod-scope-btn{super_cls}" href="{base_url}&scope=super" title="Only files not belonging to any submodule">Super-repo only</a>"#,
10123 full_cls = if full_active { " active" } else { "" },
10124 super_cls = if super_scope_active { " active" } else { "" },
10125 );
10126 for s in sub_names {
10127 let is_active = active_sub == Some(s.as_str());
10128 write!(
10129 bar,
10130 "\n <a class=\"submod-scope-btn{cls}\" href=\"{base_url}&sub={name_enc}\" title=\"Only files in submodule {name_esc}\">{name_esc}</a>",
10131 cls = if is_active { " active" } else { "" },
10132 name_enc = html_escape(s),
10133 name_esc = html_escape(s),
10134 )
10135 .unwrap();
10136 }
10137 bar.push_str("\n</div>");
10138 bar
10139}
10140
10141fn build_mc_scope_label(active_sub: Option<&str>, super_scope_active: bool) -> String {
10143 active_sub.map_or_else(
10144 || {
10145 if super_scope_active {
10146 "Super-repo only — ".to_string()
10147 } else {
10148 String::new()
10149 }
10150 },
10151 |s| format!("Submodule: {} — ", html_escape(s)),
10152 )
10153}
10154
10155#[allow(clippy::too_many_lines)]
10156#[allow(clippy::too_many_arguments)]
10157fn multi_compare_page(
10158 multi: &MultiScanComparison,
10159 project_label: &str,
10160 version: &str,
10161 csp_nonce: &str,
10162 has_submodule_data: bool,
10163 sub_names: &[String],
10164 runs_csv: &str,
10165 super_scope_active: bool,
10166 active_sub: Option<&str>,
10167 entries: &[RegistryEntry],
10168) -> String {
10169 let n = multi.points.len();
10170 let is_many = n > 4;
10171 let mc_strip_class = if is_many {
10172 "mc-strip mc-strip-grid"
10173 } else {
10174 "mc-strip"
10175 };
10176
10177 let scan_strip = build_mc_scan_strip(
10179 multi,
10180 entries,
10181 n,
10182 is_many,
10183 active_sub,
10184 super_scope_active,
10185 project_label,
10186 );
10187
10188 let (metrics_thead, metrics_tbody) = build_mc_metrics_table(multi, n);
10190
10191 let points_json = build_mc_points_json(multi, entries);
10193 let file_matrix_json = build_mc_file_matrix_json(multi);
10194
10195 let files_modified = multi
10197 .file_matrix
10198 .iter()
10199 .filter(|f| f.overall_status == "modified")
10200 .count();
10201 let files_added = multi
10202 .file_matrix
10203 .iter()
10204 .filter(|f| f.overall_status == "added")
10205 .count();
10206 let files_removed = multi
10207 .file_matrix
10208 .iter()
10209 .filter(|f| f.overall_status == "removed")
10210 .count();
10211 let files_unchanged = multi
10212 .file_matrix
10213 .iter()
10214 .filter(|f| f.overall_status == "unchanged")
10215 .count();
10216 let total_files = multi.file_matrix.len();
10217
10218 let file_col_headers = build_mc_file_col_headers(n);
10219 let nav_compare_active = "style=\"background:rgba(255,255,255,0.22);\"";
10220 let scope_bar_html = build_mc_scope_bar(
10221 has_submodule_data,
10222 sub_names,
10223 runs_csv,
10224 active_sub,
10225 super_scope_active,
10226 );
10227 let scope_label = build_mc_scope_label(active_sub, super_scope_active);
10228 let toast_assets = sloc_toast_assets(csp_nonce);
10229
10230 format!(
10231 r#"<!doctype html>
10232<html lang="en">
10233<head>
10234 <meta charset="utf-8">
10235 <meta name="viewport" content="width=device-width, initial-scale=1">
10236 <title>OxideSLOC | Multi-Scan Timeline — {project_label}</title>
10237 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
10238 <style nonce="{csp_nonce}">
10239 :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;}}
10240 *,*::before,*::after{{box-sizing:border-box;margin:0;padding:0;}}
10241 body{{background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,sans-serif;min-height:100vh;}}
10242 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;}}
10243 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
10244 .background-watermarks img{{position:absolute;opacity:0.15;filter:blur(0.3px);user-select:none;max-width:none;}}
10245 .code-particles{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
10246 .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;}}
10247 @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));}}}}
10248 .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);}}
10249 .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;}}
10250 @media(max-width:1920px){{.top-nav-inner{{max-width:1500px;}}.page{{max-width:1500px;}}}}
10251 @media(max-width:1400px){{.nav-right{{gap:6px;}}.nav-pill,.nav-dropdown-btn,.theme-toggle{{padding:0 10px;}}}}
10252 @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;}}}}
10253 .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}
10254 .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));}}
10255 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
10256 .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}
10257 .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}
10258 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}}
10259 .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;}}
10260 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
10261 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}}
10262 .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
10263 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
10264 .nav-dropdown{{position:relative;display:inline-flex;}}
10265 .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;}}
10266 .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
10267 .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;}}
10268 .nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{{opacity:1;visibility:visible;transition:opacity .13s,visibility 0s;}}
10269 .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);}}
10270 .nav-dropdown-menu a:last-child{{border-bottom:none;}}
10271 .nav-dropdown-menu a:hover{{background:rgba(255,255,255,0.14);color:#fff;}}
10272 .nav-dropdown-menu a svg{{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}}
10273 body:not(.dark-theme) .icon-sun{{display:none;}}
10274 body.dark-theme .icon-moon{{display:none;}}
10275 .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;}}
10276 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
10277 .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);}}
10278 .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;}}
10279 .settings-close:hover{{color:var(--text);background:var(--surface-2);}}
10280 .settings-close svg{{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}}
10281 .settings-modal-body{{padding:14px 16px 16px;}}
10282 .settings-modal-label{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}}
10283 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
10284 .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;}}
10285 .scheme-swatch:hover{{border-color:var(--line-strong);transform:translateY(-1px);}}
10286 .scheme-swatch.active{{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}}
10287 .scheme-preview{{width:28px;height:28px;border-radius:7px;flex-shrink:0;}}
10288 .scheme-label{{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}}
10289 .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;}}
10290 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
10291 .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;}}
10292 .btn-back:hover{{background:var(--line);}}
10293 .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;}}
10294 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;}}
10295 .mc-desc{{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}}
10296 .mc-subtitle{{font-size:14px;color:var(--muted);margin:0 0 6px;}}
10297 .mc-strip{{display:flex;align-items:stretch;flex-wrap:wrap;gap:12px;overflow:visible;padding:8px 4px 6px;margin-bottom:20px;width:100%;}}
10298 .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;}}
10299 .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;}}
10300 .mc-hero-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:16px;flex-wrap:wrap;}}
10301 .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;}}
10302 .mc-card:hover{{box-shadow:0 10px 28px rgba(77,44,20,0.18);}}
10303 body.dark-theme .mc-card{{background:var(--surface-2);}}
10304 .mc-card-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:10px;}}
10305 .mc-card-num{{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);}}
10306 .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%;}}
10307 .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;}}
10308 .mc-card-commit:hover{{color:var(--oxide);}}
10309 .mc-card-rows{{display:flex;flex-direction:column;gap:6px;}}
10310 .mc-card-row{{display:flex;align-items:baseline;gap:8px;font-size:13px;}}
10311 .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;}}
10312 .mc-row-val{{color:var(--text);font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;}}
10313 .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;}}
10314 .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;}}
10315 .mc-card-project-col{{display:flex;flex-direction:column;align-items:flex-end;gap:5px;max-width:72%;}}
10316 .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;}}
10317 .mc-scope-full{{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}}
10318 .mc-scope-sub{{background:rgba(111,155,255,0.10);border:1px solid rgba(111,155,255,0.28);color:var(--accent);}}
10319 .mc-scope-super{{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.28);color:var(--oxide);}}
10320 .mc-card-nearest-wrap{{position:relative;display:inline-flex;align-items:center;gap:4px;cursor:default;}}
10321 .mc-card-nearest{{font-size:10px;color:var(--muted-2);font-style:italic;}}
10322 .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);}}
10323 .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);}}
10324 .mc-card-nearest-wrap:hover .mc-card-nearest-tip{{display:block;}}
10325 .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;}}
10326 .cmp-author-handle{{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}}
10327 .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;}}
10328 .submod-scope-divider{{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}}
10329 .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;}}
10330 .submod-scope-label svg{{stroke:currentColor;fill:none;stroke-width:2;}}
10331 .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;}}
10332 .submod-scope-btn:hover{{background:var(--line);}}
10333 .submod-scope-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10334 .mc-arrow{{font-size:22px;color:var(--muted);align-self:center;padding:0 4px;flex-shrink:0;}}
10335 .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;}}
10336 .panel-title{{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}}
10337 .metrics-table{{width:100%;border-collapse:collapse;font-size:13px;}}
10338 .metrics-table th,.metrics-table td{{padding:9px 12px;border-bottom:1px solid var(--line);text-align:right;}}
10339 .metrics-table th{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);background:var(--surface-2);}}
10340 .metrics-table td.mc-met-label,.metrics-table th.mc-met-label{{text-align:left;font-weight:700;color:var(--text);}}
10341 .metrics-table .mc-val-col{{font-weight:700;font-variant-numeric:tabular-nums;}}
10342 .metrics-table .mc-delta-col{{font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;}}
10343 .metrics-table .mc-net-col{{font-weight:800;font-size:13px;font-variant-numeric:tabular-nums;background:rgba(111,155,255,0.06);}}
10344 .metrics-table .pos{{color:var(--pos);}}
10345 .metrics-table .neg{{color:var(--neg);}}
10346 .metrics-table .zero{{color:var(--muted);}}
10347 .metrics-table tr:hover td{{background:rgba(211,122,76,0.04);}}
10348 .chart-toolbar{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
10349 .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;}}
10350 .chart-metric-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10351 .chart-metric-btn:hover:not(.active){{background:var(--line);}}
10352 .chart-wrap{{width:100%;overflow-x:auto;}}
10353 #mc-chart{{display:block;width:100%;}}
10354 h2,.mc-charts-h2{{font-size:14px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 14px;}}
10355 .export-group{{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:4px;}}
10356 .ic-grid{{display:grid;grid-template-columns:1fr 1fr;gap:18px;}}
10357 @media(max-width:800px){{.ic-grid{{grid-template-columns:1fr;}}}}
10358 .ic-card{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
10359 body.dark-theme .ic-card{{background:var(--surface);border-color:var(--line-strong);}}
10360 .ic-card-h2{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin:0;}}
10361 .ic-card-h2-row{{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:12px;flex-wrap:wrap;}}
10362 .ic-card-h2-row .ic-card-h2{{margin:0;}}
10363 .ic-chart-hdr{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
10364 .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;}}
10365 .ic-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
10366 .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;}}
10367 .ic-svg-modal-ov.open{{display:flex;}}
10368 .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);}}
10369 .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);}}
10370 .ic-svg-modal-title{{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}}
10371 .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;}}
10372 .ic-svg-modal-close:hover{{background:var(--line);}}
10373 .ic-leg{{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}}
10374 .ic-dot{{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}}
10375 .ic-cb{{cursor:pointer;transition:opacity .17s,filter .17s,transform .17s;transform-box:fill-box;transform-origin:center center;}}
10376 .ic-cb:hover{{filter:brightness(1.15) drop-shadow(0 2px 6px rgba(0,0,0,.18));transform:scale(1.05);}}
10377 .ic-leg-item{{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}}
10378 .ic-leg-item:hover{{background:rgba(211,122,76,0.08);}}
10379 #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;}}
10380 .filter-tabs-row{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
10381 .delta-note{{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}}
10382 .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;}}
10383 .tab-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10384 .tab-btn:hover:not(.active){{background:var(--line);}}
10385 .tab-btn.tab-modified{{background:#fff2d8;color:#926000;border-color:#e6c96c;}}
10386 .tab-btn.tab-modified.active{{background:#926000;border-color:#926000;color:#fff;}}
10387 .tab-btn.tab-added{{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}}
10388 .tab-btn.tab-added.active{{background:#1a8f47;border-color:#1a8f47;color:#fff;}}
10389 .tab-btn.tab-removed{{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}}
10390 .tab-btn.tab-removed.active{{background:#b33b3b;border-color:#b33b3b;color:#fff;}}
10391 body.dark-theme .tab-btn.tab-modified{{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}}
10392 body.dark-theme .tab-btn.tab-added{{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}}
10393 body.dark-theme .tab-btn.tab-removed{{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}}
10394 .table-wrap{{width:100%;overflow-x:auto;}}
10395 #file-table{{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}}
10396 #file-table th,#file-table td{{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap;}}
10397 #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;}}
10398 #file-table th.left,#file-table td.left{{text-align:left;}}
10399 .file-scan-col,.file-delta-col,.file-net-col{{text-align:right;font-variant-numeric:tabular-nums;font-weight:600;}}
10400 .file-delta-col{{color:var(--muted);font-size:11px;}}
10401 .file-net-col{{font-weight:800;}}
10402 .pos{{color:var(--pos);}} .neg{{color:var(--neg);}} .zero{{color:var(--muted);}}
10403 #file-table th.sortable{{cursor:pointer;user-select:none;}} #file-table th.sortable:hover{{color:var(--oxide);}}
10404 #file-table .sort-icon{{margin-left:3px;font-size:9px;opacity:.4;vertical-align:middle;}}
10405 #file-table th.sort-asc .sort-icon,#file-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
10406 .status-badge{{padding:2px 7px;border-radius:4px;font-size:10px;font-weight:700;text-transform:uppercase;}}
10407 .status-badge.modified{{background:#fff2d8;color:#926000;}}
10408 .status-badge.added{{background:#e8f5ed;color:#1a8f47;}}
10409 .status-badge.removed{{background:#fdeaea;color:#b33b3b;}}
10410 .status-badge.unchanged{{background:var(--surface-2);color:var(--muted);}}
10411 body.dark-theme .status-badge.modified{{background:#3d2f0a;color:#f0c060;}}
10412 body.dark-theme .status-badge.added{{background:#163927;color:#8fe2a8;}}
10413 body.dark-theme .status-badge.removed{{background:#3d1c1c;color:#f5a3a3;}}
10414 tr.row-added td{{background:rgba(26,143,71,0.04);}}
10415 tr.row-removed td{{background:rgba(179,59,59,0.06);}}
10416 tr.row-modified td{{background:rgba(146,96,0,0.04);}}
10417 tr.row-unchanged td{{color:var(--muted);}}
10418 tr.row-unchanged .status-badge{{opacity:.65;}}
10419 .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;}}
10420 .absent{{color:var(--muted);font-style:italic;}}
10421 .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
10422 .pagination-info{{font-size:12px;color:var(--muted);}}
10423 .pagination-btns{{display:flex;gap:5px;}}
10424 .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;}}
10425 .pg-btn:hover:not(:disabled){{background:var(--line);}}
10426 .pg-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
10427 .pg-btn:disabled{{opacity:.35;cursor:default;}}
10428 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;}}
10429 .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;}}
10430 .export-btn:hover{{background:var(--line);}}
10431 .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;}}
10432 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
10433 .site-footer a{{color:var(--muted);}}
10434 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;}}
10435 body.pdf-mode{{background:#fff!important;}}
10436 body.pdf-mode .page{{padding:4px 6px 4px!important;}}
10437 .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;}}
10438 .mc-modal-overlay.open{{opacity:1;pointer-events:auto;}}
10439 .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;}}
10440 .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;}}
10441 .mc-modal-title{{font-size:18px;font-weight:800;}}
10442 .mc-modal-sub{{font-size:12px;opacity:.72;margin-top:3px;word-break:break-all;}}
10443 .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;}}
10444 .mc-modal-close:hover{{background:rgba(255,255,255,0.32);}}
10445 .mc-modal-body{{padding:18px 22px;}}
10446 .mc-modal-sec{{margin-bottom:20px;}}
10447 .mc-modal-sec-title{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:10px;}}
10448 .mc-modal-stats{{display:flex;flex-wrap:nowrap;gap:8px;margin-bottom:8px;}}
10449 .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;}}
10450 .mc-modal-stat:hover{{transform:translateY(-3px);box-shadow:0 8px 22px rgba(196,92,16,0.20);border-color:var(--oxide);}}
10451 .mc-modal-stat-val{{font-size:17px;font-weight:900;color:var(--oxide);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}
10452 .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;}}
10453 .mc-modal-row{{display:flex;gap:14px;font-size:14px;padding:9px 0;border-bottom:1px solid var(--line);align-items:baseline;}}
10454 .mc-modal-row:last-child{{border-bottom:none;}}
10455 .mc-modal-key{{color:var(--muted);font-weight:700;font-size:12px;text-transform:uppercase;letter-spacing:.04em;flex-shrink:0;min-width:160px;}}
10456 .mc-modal-val{{color:var(--text);font-size:14.5px;font-weight:600;word-break:break-all;}}
10457 .mc-modal-val a{{color:var(--oxide);text-decoration:none;font-weight:700;}}
10458 .mc-modal-val a:hover{{text-decoration:underline;}}
10459 body.dark-theme .mc-modal-stat{{background:rgba(255,255,255,0.07);}}
10460 body.dark-theme .mc-modal-stat:hover{{box-shadow:0 8px 22px rgba(0,0,0,0.40);}}
10461 .mc-modal-stat[data-tip]{{cursor:help;}}
10462 #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);}}
10463 .mc-card{{cursor:pointer;}}
10464 .mc-card:hover{{transform:translateY(-4px);box-shadow:0 10px 28px rgba(196,92,16,0.24);z-index:10;}}
10465 </style>
10466</head>
10467<body>
10468 {loading_overlay}
10469 <div class="background-watermarks" aria-hidden="true">
10470 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10471 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10472 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10473 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10474 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10475 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
10476 </div>
10477 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
10478 <div class="top-nav">
10479 <div class="top-nav-inner">
10480 <a class="brand" href="/">
10481 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
10482 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Multi-Scan Timeline</div></div>
10483 </a>
10484 <div class="nav-right">
10485 <a class="nav-pill" href="/">Home</a>
10486 <div class="nav-dropdown">
10487 <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>
10488 <div class="nav-dropdown-menu">
10489 <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>
10490 </div>
10491 </div>
10492 <a class="nav-pill" href="/compare-scans" {nav_compare_active}>Compare Scans</a>
10493 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
10494 <div class="nav-dropdown">
10495 <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>
10496 <div class="nav-dropdown-menu">
10497 <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>
10498 </div>
10499 </div>
10500 <div class="server-status-wrap" id="server-status-wrap">
10501 <div class="nav-pill server-online-pill" id="server-status-pill">
10502 <span class="status-dot" id="status-dot"></span>
10503 <span id="server-status-label">Server</span>
10504 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
10505 </div>
10506 <div class="server-status-tip">
10507 OxideSLOC is running — accessible on your network.
10508 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
10509 </div>
10510 </div>
10511 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
10512 <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>
10513 </button>
10514 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
10515 <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>
10516 <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>
10517 </button>
10518 </div>
10519 </div>
10520 </div>
10521
10522 <div class="page">
10523 <!-- Hero header -->
10524 <div class="mc-hero">
10525 <div class="mc-hero-header">
10526 <div>
10527 <div class="mc-title">Multi-Scan Timeline</div>
10528 <p class="mc-desc">Side-by-side metric comparison across multiple scans — code line progression, file changes, and language breakdown.</p>
10529 <div class="mc-subtitle">{scope_label}{n} scans · project: <strong>{project_label}</strong></div>
10530 </div>
10531 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
10532 <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>
10533 <div class="export-group" id="mc-top-export-group">
10534 <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>
10535 <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>
10536 </div>
10537 </div>
10538 </div>
10539 {scope_bar_html}
10540 <!-- Scan strip -->
10541 <div class="{mc_strip_class}">{scan_strip}</div>
10542 </div>
10543
10544 <!-- Summary metrics table -->
10545 <div class="panel">
10546 <div class="panel-title">Metric Progression</div>
10547 <div class="table-wrap">
10548 <table class="metrics-table">
10549 <thead>{metrics_thead}</thead>
10550 <tbody>{metrics_tbody}</tbody>
10551 </table>
10552 </div>
10553 </div>
10554
10555 <!-- Scan Charts -->
10556 <div class="panel" id="mc-charts-panel">
10557 <div class="panel-title" style="margin-bottom:14px;">Scan Delta Charts</div>
10558 <div class="ic-grid">
10559 <!-- Timeline line chart — spans full width -->
10560 <div class="ic-card" style="grid-column:span 2">
10561 <div class="ic-card-h2-row">
10562 <span class="ic-card-h2">Timeline</span>
10563 <div class="chart-toolbar" style="margin:0">
10564 <button class="chart-metric-btn active" data-metric="code">Code Lines</button>
10565 <button class="chart-metric-btn" data-metric="files">Files</button>
10566 <button class="chart-metric-btn" data-metric="comments">Comments</button>
10567 <button class="chart-metric-btn" data-metric="tests">Tests</button>
10568 <button class="chart-metric-btn" data-metric="cov">Coverage</button>
10569 </div>
10570 </div>
10571 <div class="chart-wrap"><svg id="mc-chart" height="280"></svg></div>
10572 </div>
10573 <!-- Code Metrics: Scan 1 vs Latest -->
10574 <div class="ic-card">
10575 <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>
10576 <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>
10577 <div id="mc-ic-c1"></div>
10578 </div>
10579 <!-- Language Code Delta -->
10580 <div class="ic-card" id="mc-ic-lang-card">
10581 <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>
10582 <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>
10583 <div id="mc-ic-c3"></div>
10584 </div>
10585 <!-- Delta by Metric -->
10586 <div class="ic-card">
10587 <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>
10588 <div id="mc-ic-c2"></div>
10589 </div>
10590 <!-- File Change Distribution -->
10591 <div class="ic-card">
10592 <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>
10593 <div id="mc-ic-c4"></div>
10594 </div>
10595 </div>
10596 </div>
10597
10598 <!-- File matrix table -->
10599 <div class="panel">
10600 <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>
10601 <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
10602 <div class="filter-tabs-row" style="margin-bottom:0;gap:6px;">
10603 <button class="tab-btn tab-all active" data-status="">All ({total_files})</button>
10604 <button class="tab-btn tab-modified" data-status="modified">Modified ({files_modified})</button>
10605 <button class="tab-btn tab-added" data-status="added">Added ({files_added})</button>
10606 <button class="tab-btn tab-removed" data-status="removed">Removed ({files_removed})</button>
10607 <button class="tab-btn tab-unchanged" data-status="unchanged">Unchanged ({files_unchanged})</button>
10608 </div>
10609 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;flex-shrink:0;">
10610 <span class="delta-note">* Δ = delta (change from scan 1 → latest)</span>
10611 <div class="export-group">
10612 <button type="button" class="export-btn" id="mc-file-reset-btn">↻ Reset</button>
10613 <button type="button" class="export-btn" id="export-csv-btn">
10614 <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>
10615 CSV
10616 </button>
10617 <button type="button" class="export-btn" id="mc-file-xls-btn">
10618 <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>
10619 Excel
10620 </button>
10621 </div>
10622 </div>
10623 </div>
10624 <div class="table-wrap">
10625 <table id="file-table">
10626 <thead>
10627 <tr>
10628 <th class="left sortable" data-sort-col="p" data-sort-type="str">File <span class="sort-icon">↕</span></th>
10629 <th class="left sortable" data-sort-col="l" data-sort-type="str">Language <span class="sort-icon">↕</span></th>
10630 <th class="left sortable" data-sort-col="s" data-sort-type="str">Status <span class="sort-icon">↕</span></th>
10631 {file_col_headers}
10632 <th class="file-net-col sortable" data-sort-col="t" data-sort-type="num">Net Δ <span class="sort-icon">↕</span></th>
10633 </tr>
10634 </thead>
10635 <tbody id="file-tbody"></tbody>
10636 </table>
10637 </div>
10638 <div class="pagination">
10639 <span class="pagination-info" id="pg-info"></span>
10640 <div class="pagination-btns" id="pg-btns"></div>
10641 <div style="display:flex;align-items:center;gap:6px;">
10642 <span style="font-size:12px;color:var(--muted)">Show</span>
10643 <select class="per-page" id="per-page-sel">
10644 <option value="25" selected>25 per page</option>
10645 <option value="50">50 per page</option>
10646 <option value="100">100 per page</option>
10647 </select>
10648 </div>
10649 </div>
10650 </div>
10651 </div>
10652
10653 <div id="mc-ic-tt"></div>
10654
10655 <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
10656 <div class="ic-svg-modal">
10657 <div class="ic-svg-modal-hdr">
10658 <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
10659 <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">× Close</button>
10660 </div>
10661 <div id="ic-svg-modal-body"></div>
10662 </div>
10663 </div>
10664
10665 <footer class="site-footer">
10666 oxide-sloc v{version} — local code metrics workbench ·
10667 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
10668 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
10669 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
10670 · <a href="/api-docs" rel="noopener">REST API</a>
10671 </footer>
10672
10673 <script nonce="{csp_nonce}">
10674 (function(){{
10675 // ── Dark theme ───────────────────────────────────────────────────────────
10676 try{{if(localStorage.getItem('sloc-dark')==='1')document.body.classList.add('dark-theme');}}catch(e){{}}
10677 var renderInlineCharts=null;
10678 var tt=document.getElementById('theme-toggle');
10679 if(tt)tt.addEventListener('click',function(){{
10680 var on=document.body.classList.toggle('dark-theme');
10681 try{{localStorage.setItem('sloc-dark',on?'1':'0');}}catch(e){{}}
10682 renderChart(activeMetric);
10683 if(renderInlineCharts)renderInlineCharts();
10684 }});
10685
10686 // ── Code particles ───────────────────────────────────────────────────────
10687 var container=document.getElementById('code-particles');
10688 if(container){{
10689 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()'];
10690 for(var i=0;i<28;i++){{
10691 (function(idx){{
10692 var el=document.createElement('span');el.className='code-particle';
10693 el.textContent=snips[idx%snips.length];
10694 el.style.left=(Math.random()*94+2).toFixed(1)+'%';
10695 el.style.top=(Math.random()*88+6).toFixed(1)+'%';
10696 el.style.setProperty('--rot',(Math.random()*26-13).toFixed(1)+'deg');
10697 el.style.setProperty('--op',(Math.random()*0.08+0.05).toFixed(3));
10698 el.style.animationDuration=(Math.random()*10+9).toFixed(1)+'s';
10699 el.style.animationDelay='-'+(Math.random()*18).toFixed(1)+'s';
10700 container.appendChild(el);
10701 }})(i);
10702 }}
10703 }}
10704
10705 // ── Watermarks ───────────────────────────────────────────────────────────
10706 var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
10707 if(wms.length){{
10708 var placed=[];
10709 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;}}
10710 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];}}
10711 var half=Math.floor(wms.length/2);
10712 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;}});
10713 }}
10714
10715 // ── Settings / colour scheme modal ───────────────────────────────────────
10716 (function(){{
10717 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'}}];
10718 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);}});}}
10719 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a)ap(sv);else ap(S[0]);}}catch(e){{ap(S[0]);}}
10720 function init(){{
10721 var btn=document.getElementById('settings-btn');if(!btn)return;
10722 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
10723 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>';
10724 document.body.appendChild(m);
10725 var g=document.getElementById('scheme-grid');
10726 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);}});
10727 var cl=document.getElementById('settings-close-btn');
10728 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');}});
10729 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
10730 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
10731 }}
10732 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
10733 }})();
10734
10735 // ── Timezone support for scan timestamps ─────────────────────────────────
10736 (function(){{
10737 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);}};
10738 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'';}}}};
10739 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);}});}};
10740 var storedTz;try{{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{storedTz='America/Los_Angeles';}}
10741 window.applyTz(storedTz);
10742 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);}});}}}}
10743 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',wireTzSelect);else setTimeout(wireTzSelect,50);
10744 }})();
10745
10746 // ── Data ────────────────────────────────────────────────────────────────
10747 var POINTS={points_json};
10748 var FILES={file_matrix_json};
10749 var N={n};
10750
10751 // ── fmt helper ───────────────────────────────────────────────────────────
10752 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();}}
10753 function fmtFull(n){{return Number(n).toLocaleString();}}
10754 function fmtDelta(n){{return n>0?'+'+fmtFull(n):fmtFull(n);}}
10755
10756 // ── Export filename: <project>_<n_scans>_<first_scan_short_commit> ──
10757 function mcExportProj(){{return ('{project_label}'.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,''))||'project';}}
10758 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));}}
10759 function mcExportBase(){{var first=POINTS.length?mcShortRef(POINTS[0],0):'scan1';return mcExportProj()+'_'+POINTS.length+'_'+first;}}
10760 function mcExportName(ext){{return mcExportBase()+'.'+ext;}}
10761
10762 // ── Timeline chart ───────────────────────────────────────────────────────
10763 var activeMetric='code';
10764 var metricKey={{code:'code',files:'files',comments:'comments',tests:'tests',cov:'cov'}};
10765 var metricLabel={{code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'}};
10766
10767 function renderChart(metric){{
10768 var svg=document.getElementById('mc-chart');if(!svg)return;
10769 var W=svg.getBoundingClientRect().width||800,H=280;
10770 svg.setAttribute('height',H);
10771 var pad={{l:62,r:20,t:32,b:72}};
10772 var dark=document.body.classList.contains('dark-theme');
10773 var pts=POINTS.map(function(p){{return p[metric]!=null?Number(p[metric]):null;}});
10774 var valid=pts.filter(function(v){{return v!=null;}});
10775 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;}}
10776 var minV=0,maxV=Math.max.apply(null,valid);
10777 if(maxV<=0){{maxV=1;}}else{{maxV=maxV*1.08;}}
10778 var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
10779 function xOf(i){{return pad.l+(N===1?plotW/2:i/(N-1)*plotW);}}
10780 function yOf(v){{return pad.t+plotH-(v-minV)/(maxV-minV)*plotH;}}
10781 var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
10782 var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
10783 var lineColor='#d37a4c';var dotColor='#d37a4c';var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
10784 var parts=[];
10785 parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
10786 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>');}}
10787 var areaD='M '+xOf(0)+' '+(pad.t+plotH);
10788 var lineD='';var firstPt=true;
10789 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);}}}}
10790 areaD+=' L '+xOf(N-1)+' '+(pad.t+plotH)+' Z';
10791 parts.push('<path d="'+areaD+'" fill="'+areaColor+'"/>');
10792 parts.push('<path d="'+lineD+'" fill="none" stroke="'+lineColor+'" stroke-width="2.2" stroke-linejoin="round"/>');
10793 for(var i=0;i<N;i++){{
10794 if(pts[i]==null)continue;
10795 var cx=xOf(i),cy=yOf(pts[i]);
10796 var p=POINTS[i];var lbl=(p.commit||'').substring(0,7)||(i+1)+'';
10797 var hasTag=p.tags&&p.tags.length>0;
10798 // Permanent Y-value label above the dot
10799 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>');
10800 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+'"/>');
10801 var xanchor=i===0?'start':i===N-1?'end':'middle';
10802 // X-axis label at 2× the original size (18 px)
10803 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>');
10804 }}
10805 parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escHtml(metricLabel[metric]||metric)+'</text>');
10806 svg.setAttribute('viewBox','0 0 '+W+' '+H);
10807 svg.innerHTML=parts.join('');
10808 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');}});
10809 // ── Interactive hover: vertical crosshair + tooltip ───────────────────
10810 svg.onmousemove=function(e){{
10811 var rect=svg.getBoundingClientRect();
10812 var scaleX=W/rect.width;
10813 var mouseX=(e.clientX-rect.left)*scaleX;
10814 var nearest=-1,minDist=Infinity;
10815 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;}}}}
10816 if(nearest<0)return;
10817 var nc=xOf(nearest),ny=yOf(pts[nearest]);
10818 var xhair=svg.querySelector('.mc-xhair');
10819 if(!xhair){{xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','mc-xhair');svg.appendChild(xhair);}}
10820 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"/>';
10821 var tt=document.getElementById('mc-ic-tt');if(!tt)return;
10822 var pp=POINTS[nearest];var clbl=(pp.commit||'').substring(0,7)||(nearest+1)+'';
10823 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>';
10824 var bx=rect.left+(nc/W*rect.width)+18;
10825 if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
10826 tt.style.left=bx+'px';tt.style.top=(e.clientY-38)+'px';tt.style.display='block';
10827 }};
10828 svg.onmouseleave=function(){{
10829 var xhair=svg.querySelector('.mc-xhair');if(xhair)xhair.innerHTML='';
10830 var tt=document.getElementById('mc-ic-tt');if(tt)tt.style.display='none';
10831 }};
10832 }}
10833
10834 function escHtml(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
10835
10836 document.querySelectorAll('.chart-metric-btn').forEach(function(btn){{
10837 btn.addEventListener('click',function(){{
10838 activeMetric=this.dataset.metric;
10839 document.querySelectorAll('.chart-metric-btn').forEach(function(b){{b.classList.remove('active');}});
10840 this.classList.add('active');
10841 renderChart(activeMetric);
10842 }});
10843 }});
10844 if(typeof ResizeObserver!=='undefined'){{
10845 new ResizeObserver(function(){{renderChart(activeMetric);}}).observe(document.getElementById('mc-chart'));
10846 }}
10847 renderChart(activeMetric);
10848
10849 // ── File matrix table ────────────────────────────────────────────────────
10850 var activeStatus='';
10851 var currentPage=1;
10852 var perPage=25;
10853 var mcSortCol=null,mcSortAsc=true;
10854
10855 function getFiltered(){{
10856 var data=!activeStatus?FILES:FILES.filter(function(f){{return f.s===activeStatus;}});
10857 if(!mcSortCol)return data;
10858 var asc=mcSortAsc;
10859 return data.slice().sort(function(a,b){{
10860 var va,vb;
10861 if(mcSortCol==='p'){{va=a.p||'';vb=b.p||'';}}
10862 else if(mcSortCol==='l'){{va=a.l||'';vb=b.l||'';}}
10863 else if(mcSortCol==='s'){{va=a.s||'';vb=b.s||'';}}
10864 else if(mcSortCol==='t'){{va=a.t||0;vb=b.t||0;return asc?va-vb:vb-va;}}
10865 else{{return 0;}}
10866 if(asc)return va<vb?-1:va>vb?1:0;
10867 return va<vb?1:va>vb?-1:0;
10868 }});
10869 }}
10870
10871 function renderFilePage(){{
10872 var filtered=getFiltered();
10873 var total=filtered.length;
10874 var totalPages=Math.max(1,Math.ceil(total/perPage));
10875 if(currentPage>totalPages)currentPage=totalPages;
10876 var start=(currentPage-1)*perPage,end=Math.min(start+perPage,total);
10877 var tbody=document.getElementById('file-tbody');if(!tbody)return;
10878 var rows=[];
10879 for(var i=start;i<end;i++){{
10880 var f=filtered[i];
10881 var cells='<td class="left"><span class="file-path" title="'+escHtml(f.p)+'">'+escHtml(f.p)+'</span></td>';
10882 cells+='<td class="left">'+(f.l?escHtml(f.l):'<span class="absent">\u2014</span>')+'</td>';
10883 cells+='<td class="left"><span class="status-badge '+f.s+'">'+f.s+'</span></td>';
10884 for(var j=0;j<N;j++){{
10885 var cv=f.c[j];
10886 cells+='<td class="file-scan-col">'+(cv!=null?fmtFull(cv):'<span class="absent">\u2014</span>')+'</td>';
10887 if(j<N-1){{
10888 var dv=f.d[j+1];
10889 cells+='<td class="file-delta-col '+(dv!=null?dv>0?'pos':dv<0?'neg':'zero':'absent-delta')+'">'+
10890 (dv!=null?fmtDelta(dv):'<span class="absent">\u2014</span>')+'</td>';
10891 }}
10892 }}
10893 var tc=f.t;
10894 cells+='<td class="file-net-col '+(tc>0?'pos':tc<0?'neg':'zero')+'">'+fmtDelta(tc)+'</td>';
10895 rows.push('<tr class="row-'+f.s+'">'+cells+'</tr>');
10896 }}
10897 tbody.innerHTML=rows.join('');
10898
10899 var info=document.getElementById('pg-info');
10900 if(info)info.textContent='Showing '+(total?start+1:0)+'\u2013'+end+' of '+total+' files';
10901 renderPgBtns(totalPages);
10902 }}
10903
10904 function renderPgBtns(totalPages){{
10905 var wrap=document.getElementById('pg-btns');if(!wrap)return;
10906 var btns=[];
10907 function mkBtn(label,page,active,disabled){{
10908 var cls='pg-btn'+(active?' active':'')+(disabled?' disabled':'');
10909 return '<button class="'+cls+'" data-pg="'+page+'" '+(disabled?'disabled':'')+'>'+label+'</button>';
10910 }}
10911 btns.push(mkBtn('‹',currentPage-1,false,currentPage<=1));
10912 var s=Math.max(1,currentPage-2),e=Math.min(totalPages,currentPage+2);
10913 if(s>1)btns.push(mkBtn('1',1,false,false));
10914 if(s>2)btns.push('<span class="pg-btn" style="pointer-events:none">…</span>');
10915 for(var p=s;p<=e;p++)btns.push(mkBtn(p,p,p===currentPage,false));
10916 if(e<totalPages-1)btns.push('<span class="pg-btn" style="pointer-events:none">…</span>');
10917 if(e<totalPages)btns.push(mkBtn(totalPages,totalPages,false,false));
10918 btns.push(mkBtn('›',currentPage+1,false,currentPage>=totalPages));
10919 wrap.innerHTML=btns.join('');
10920 wrap.querySelectorAll('.pg-btn[data-pg]').forEach(function(b){{
10921 b.addEventListener('click',function(){{
10922 var pg=parseInt(this.dataset.pg,10);
10923 if(pg>=1&&pg<=totalPages){{currentPage=pg;renderFilePage();}}
10924 }});
10925 }});
10926 }}
10927
10928 // Tab filter
10929 document.querySelectorAll('.tab-btn').forEach(function(btn){{
10930 btn.addEventListener('click',function(){{
10931 activeStatus=this.dataset.status||'';
10932 currentPage=1;
10933 document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
10934 this.classList.add('active');
10935 renderFilePage();
10936 }});
10937 }});
10938
10939 // Per-page selector
10940 var ppSel=document.getElementById('per-page-sel');
10941 if(ppSel)ppSel.addEventListener('change',function(){{perPage=parseInt(this.value,10)||25;currentPage=1;renderFilePage();}});
10942
10943 // ── Column header sort ───────────────────────────────────────────────────
10944 Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(th){{
10945 th.addEventListener('click',function(){{
10946 var col=th.dataset.sortCol;
10947 if(mcSortCol===col){{mcSortAsc=!mcSortAsc;}}else{{mcSortCol=col;mcSortAsc=true;}}
10948 Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
10949 var si=t.querySelector('.sort-icon');if(si)si.innerHTML='↕';t.classList.remove('sort-asc','sort-desc');
10950 }});
10951 th.classList.add(mcSortAsc?'sort-asc':'sort-desc');
10952 var si=th.querySelector('.sort-icon');if(si)si.innerHTML=mcSortAsc?'↑':'↓';
10953 currentPage=1;renderFilePage();
10954 }});
10955 }});
10956
10957 // Reset button also clears sort
10958 var mcResetBtn=document.getElementById('mc-file-reset-btn');
10959 if(mcResetBtn)mcResetBtn.addEventListener('click',function(){{
10960 mcSortCol=null;mcSortAsc=true;
10961 Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
10962 var si=t.querySelector('.sort-icon');if(si)si.innerHTML='↕';t.classList.remove('sort-asc','sort-desc');
10963 }});
10964 activeStatus='';currentPage=1;
10965 document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
10966 var allBtn=document.querySelector('.tab-btn');if(allBtn)allBtn.classList.add('active');
10967 renderFilePage();
10968 }});
10969
10970 renderFilePage();
10971
10972 // ── CSV export ───────────────────────────────────────────────────────────
10973 var exportBtn=document.getElementById('export-csv-btn');
10974 if(exportBtn)exportBtn.addEventListener('click',function(){{
10975 var header=['File','Language','Status'];
10976 for(var i=0;i<N;i++){{header.push('Scan '+(i+1)+' Code');if(i<N-1)header.push('Delta->'+(i+2));}}
10977 header.push('Net Delta');
10978 var rows=[header.map(function(h){{return '"'+h.replace(/"/g,'""')+'"';}}).join(',')];
10979 var filtered=getFiltered();
10980 filtered.forEach(function(f){{
10981 var cols=['"'+f.p.replace(/"/g,'""')+'"','"'+(f.l||'')+'"','"'+f.s+'"'];
10982 for(var j=0;j<N;j++){{
10983 cols.push(f.c[j]!=null?f.c[j]:'');
10984 if(j<N-1)cols.push(f.d[j+1]!=null?f.d[j+1]:'');
10985 }}
10986 cols.push(f.t);
10987 rows.push(cols.join(','));
10988 }});
10989 var blob=new Blob([rows.join('\r\n')],{{type:'text/csv'}});
10990 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
10991 a.download=mcExportName('csv');a.click();
10992 }});
10993
10994 // ── File matrix extra export buttons ─────────────────────────────────────
10995 (function(){{
10996 var resetBtn=document.getElementById('mc-file-reset-btn');
10997 if(resetBtn)resetBtn.addEventListener('click',function(){{
10998 activeStatus='';currentPage=1;
10999 document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
11000 var allBtn=document.querySelector('.tab-btn.tab-all');if(allBtn)allBtn.classList.add('active');
11001 renderFilePage();
11002 }});
11003
11004 // \u2500\u2500 File Matrix Excel export \u2014 Summary + File Delta tabs (matches Scan Delta) \u2500\u2500
11005 function mcSignDelta(v){{if(v==null||v==='')return'';var n=+v;return n>0?'+'+n:String(n);}}
11006 function mcMakeXlsx(fname){{
11007 var filtered=getFiltered();
11008 var enc=new TextEncoder();
11009 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;}}
11010 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;}}
11011 function u2(n){{return[n&0xFF,(n>>8)&0xFF];}}
11012 function u4(n){{return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}}
11013 var ss=[],si={{}};
11014 function S(v){{v=String(v==null?'':v);if(!(v in si)){{si[v]=ss.length;ss.push(v);}}return si[v];}}
11015 function xe(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
11016 function WS(){{
11017 var R=0,buf=[];
11018 function cl(c){{return String.fromCharCode(65+c);}}
11019 function sc(c,v,st){{return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'><v>'+S(v)+'</v></c>';}}
11020 function nc(c,v,st){{return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+(st?' s="'+st+'"':'')+'><v>'+(+v)+'</v></c>';}}
11021 function row(cells){{if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}}
11022 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>';}}
11023 return{{sc:sc,nc:nc,row:row,xml:xml}};
11024 }}
11025 function dstyle(v){{var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}}
11026 var proj=mcExportProj();
11027 // \u2500\u2500 Summary sheet \u2500\u2500
11028 var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
11029 r1(s1(0,'OxideSLOC \u2014 Multi-Scan Timeline Report',1));
11030 r1(s1(0,proj,2));
11031 var firstTs=POINTS.length?(POINTS[0].scanned||''):'',lastTs=POINTS.length?(POINTS[POINTS.length-1].scanned||''):'';
11032 r1(s1(0,firstTs+' \u2192 '+lastTs+' ('+N+' scans)',2));
11033 r1('');
11034 r1(s1(0,'SCAN SUMMARY',8));
11035 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));
11036 POINTS.forEach(function(p,i){{
11037 var sha=(p.commit||'').replace(/[^A-Za-z0-9]/g,'').slice(0,7);
11038 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));
11039 }});
11040 r1('');
11041 if(POINTS.length>1){{
11042 var pf=POINTS[0],pl=POINTS[POINTS.length-1];
11043 r1(s1(0,'NET CHANGE (Scan 1 \u2192 Scan '+N+')',8));
11044 r1(s1(0,'Metric',3)+s1(1,'Scan 1',3)+s1(2,'Scan '+N,3)+s1(3,'Delta',3));
11045 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)));}};
11046 nr('Code Lines',pf.code,pl.code);
11047 nr('Comment Lines',pf.comments,pl.comments);
11048 nr('Files Analyzed',pf.files,pl.files);
11049 nr('Tests',pf.tests,pl.tests);
11050 r1('');
11051 }}
11052 var cMod=0,cAdd=0,cRem=0,cUnch=0;
11053 FILES.forEach(function(f){{var s=f.s;if(s==='modified')cMod++;else if(s==='added')cAdd++;else if(s==='removed')cRem++;else cUnch++;}});
11054 var totF=FILES.length||1;
11055 function pct(n){{return(n/totF*100).toFixed(1)+'%';}}
11056 r1(s1(0,'FILE CHANGES',8));
11057 r1(s1(0,'Category',3)+s1(1,'Count',3)+s1(2,'% of Total',3));
11058 r1(s1(0,'Modified')+n1(1,cMod,4)+s1(2,pct(cMod)));
11059 r1(s1(0,'Added')+n1(1,cAdd,4)+s1(2,pct(cAdd)));
11060 r1(s1(0,'Removed')+n1(1,cRem,4)+s1(2,pct(cRem)));
11061 r1(s1(0,'Unchanged')+n1(1,cUnch,4)+s1(2,pct(cUnch)));
11062 r1(s1(0,'Total')+n1(1,cMod+cAdd+cRem+cUnch,4)+s1(2,pct(cMod+cAdd+cRem+cUnch)));
11063 var lm={{}};
11064 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;}});
11065 var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}});
11066 if(langs.length){{
11067 r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
11068 r1(s1(0,'Language',3)+s1(1,'Files',3)+s1(2,'Net Code Delta',3));
11069 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)));}});
11070 }}
11071 var sh1=W1.xml('<col min="1" max="1" width="22" customWidth="1"/><col min="2" max="8" width="15" customWidth="1"/>');
11072 // \u2500\u2500 File Delta sheet \u2500\u2500
11073 var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
11074 var hcells=s2(0,'File',3)+s2(1,'Language',3)+s2(2,'Status',3),hc=3;
11075 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);}}
11076 hcells+=s2(hc,'Net Delta',3);
11077 r2(hcells);
11078 filtered.forEach(function(f){{
11079 var cells=s2(0,f.p)+s2(1,f.l||'')+s2(2,f.s||''),c=3;
11080 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));}}}}
11081 var tv=mcSignDelta(f.t);cells+=s2(c,tv,dstyle(tv));
11082 r2(cells);
11083 }});
11084 var ncols=3+N+(N-1)+1;
11085 var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="'+ncols+'" width="13" customWidth="1"/>');
11086 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>';
11087 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
11088 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>',
11089 '_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>',
11090 '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>',
11091 '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>',
11092 '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>',
11093 'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2}};
11094 var zparts=[],zcds=[],zoff=0,znf=0;
11095 ['[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){{
11096 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
11097 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]);
11098 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);
11099 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));
11100 var cde=new Uint8Array(cda.length+nb.length);cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);zcds.push(cde);
11101 zoff+=entry.length;znf++;
11102 }});
11103 var cdSz=zcds.reduce(function(s,b){{return s+b.length;}},0);
11104 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]);
11105 var totalLen=zoff+cdSz+eocd.length,out=new Uint8Array(totalLen),pos=0;
11106 zparts.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
11107 zcds.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
11108 out.set(new Uint8Array(eocd),pos);
11109 var blob=new Blob([out],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}});
11110 var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
11111 }}
11112
11113 var xlsBtn=document.getElementById('mc-file-xls-btn');
11114 if(xlsBtn)xlsBtn.addEventListener('click',function(){{mcMakeXlsx(mcExportName('xlsx'));}});
11115
11116 // File matrix HTML export — interactive: sort by column, filter by status
11117 function mcFileBuildHtml(){{
11118 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
11119 var hdrs=['File','Language','Status'];
11120 for(var _i=0;_i<N;_i++){{hdrs.push('Scan '+(_i+1)+' Code');if(_i<N-1)hdrs.push('\u0394\u2192'+(_i+2));}}
11121 hdrs.push('Net \u0394');
11122 var SI=2;
11123 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;}});
11124 var dJson=JSON.stringify(allRows),hJson=JSON.stringify(hdrs);
11125 var cnt={{all:allRows.length}};
11126 allRows.forEach(function(r){{var s=r[SI];cnt[s]=(cnt[s]||0)+1;}});
11127 var now=new Date().toISOString().replace('T',' ').slice(0,16)+' UTC';
11128 var css='body{{margin:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#f5f2ee;color:#111;}}'+
11129 '.hd{{background:#1a2035;color:#fff;padding:14px 20px;display:flex;justify-content:space-between;align-items:flex-start;}}'+
11130 '.brand{{font-size:13px;font-weight:800;color:#c45c10;letter-spacing:.06em;}}'+
11131 '.ttl{{font-size:18px;font-weight:700;margin:2px 0 3px;}}'+
11132 '.sub{{font-size:12px;color:#99aabb;}}'+
11133 '.pg-meta{{font-size:11px;color:#8899aa;text-align:right;line-height:1.8;}}'+
11134 '.wr{{padding:16px 20px;}}'+
11135 '.fbar{{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;}}'+
11136 '.fb{{padding:4px 12px;border-radius:20px;border:1px solid #ccc;background:#fff;font-size:12px;font-weight:600;cursor:pointer;transition:all .12s;}}'+
11137 '.fb.on{{background:#c45c10;color:#fff;border-color:#c45c10;}}'+
11138 '.ibar{{font-size:12px;color:#888;margin-bottom:8px;}}'+
11139 '.tw{{overflow-x:auto;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,.09);}}'+
11140 'table{{width:100%;border-collapse:collapse;background:#fff;font-size:12px;}}'+
11141 'thead tr{{background:#1a2035;}}'+
11142 '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;}}'+
11143 'th:hover{{background:#2a3050;}}'+
11144 'th span{{margin-left:4px;opacity:.55;font-size:10px;}}'+
11145 'td{{padding:5px 10px;border-bottom:1px solid #f0ece8;}}'+
11146 'tr:nth-child(even) td{{background:#faf7f4;}}'+
11147 'tr:hover td{{background:#f5f0ea;}}'+
11148 '.ap{{color:#2a6846;font-weight:700;}}.an{{color:#b23030;font-weight:700;}}'+
11149 '.ftr{{background:#1a2035;color:#7a8b9c;font-size:10px;padding:7px 20px;display:flex;justify-content:space-between;margin-top:16px;}}';
11150 var thH=hdrs.map(function(h,i){{return'<th data-ci="'+i+'">'+esc(h)+'<span>\u21c5</span></th>';}}).join('');
11151 var fH='<button class="fb on" data-f="">All ('+allRows.length+')</button>'+
11152 (cnt.modified?'<button class="fb" data-f="modified">Modified ('+cnt.modified+')</button>':'')+
11153 (cnt.added?'<button class="fb" data-f="added">Added ('+cnt.added+')</button>':'')+
11154 (cnt.removed?'<button class="fb" data-f="removed">Removed ('+cnt.removed+')</button>':'')+
11155 (cnt.unchanged?'<button class="fb" data-f="unchanged">Unchanged ('+cnt.unchanged+')</button>':'');
11156 var inlineJs='var ALL='+dJson+',HDRS='+hJson+',SI='+SI+',sc=-1,sd=1,sf="";'+
11157 'function fc(v,ci){{if(v==null)return"—";var s=String(v);'+
11158 'if(ci===SI){{return s==="added"?"<span class=\\"ap\\">added<\\/span>":s==="removed"?"<span class=\\"an\\">removed<\\/span>":s||"—";}}'+
11159 '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>";}}'+
11160 'if(ci>=3&&typeof v==="number")return Number(v).toLocaleString();'+
11161 'return s.length>80?"<abbr title=\\""+s.replace(/"/g,""")+"\\" style=\\"cursor:help\\">"+s.slice(0,78)+"\u2026<\\/abbr>":esc(s);}}'+
11162 'function esc(s){{return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");}}'+
11163 'function render(){{var data=sf?ALL.filter(function(r){{return r[SI]===sf;}}):ALL.slice();'+
11164 'if(sc>=0)data.sort(function(a,b){{var av=a[sc],bv=b[sc];var an=Number(av),bn=Number(bv);'+
11165 'return(!isNaN(an)&&!isNaN(bn)?an-bn:String(av||"").localeCompare(String(bv||"")))*sd;}});'+
11166 '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("")'+
11167 '||"<tr><td colspan=\\""+HDRS.length+"\\" style=\\"text-align:center;color:#aaa;padding:14px\\">No files match.<\\/td><\\/tr>";'+
11168 'document.getElementById("ic").textContent=data.length+" of "+ALL.length+" files";}}'+
11169 'document.querySelectorAll(".fb").forEach(function(b){{b.onclick=function(){{sf=this.dataset.f||"";'+
11170 'document.querySelectorAll(".fb").forEach(function(x){{x.classList.remove("on");}});this.classList.add("on");render();}};}} );'+
11171 'document.querySelectorAll("th[data-ci]").forEach(function(th){{th.onclick=function(){{var ci=+this.dataset.ci;'+
11172 'sd=(sc===ci)?-sd:1;sc=ci;'+
11173 'document.querySelectorAll("th[data-ci]").forEach(function(t){{t.querySelector("span").textContent="\u21c5";}});'+
11174 'this.querySelector("span").textContent=sd>0?"\u25b2":"\u25bc";render();}};}} );'+
11175 'render();';
11176 return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>Multi-Scan File Matrix<\/title><style>'+css+'<\/style><\/head><body>'+
11177 '<div class="hd"><div><div class="brand">oxide-sloc<\/div><div class="ttl">Multi-Scan File Matrix<\/div>'+
11178 '<div class="sub">{project_label} · {n} scans<\/div><\/div>'+
11179 '<div class="pg-meta">'+allRows.length+' files<br>Generated: '+now+'<\/div><\/div>'+
11180 '<div class="wr"><div class="fbar">'+fH+'<\/div><div class="ibar" id="ic"><\/div>'+
11181 '<div class="tw"><table><thead><tr>'+thH+'<\/tr><\/thead><tbody id="tb"><\/tbody><\/table><\/div><\/div>'+
11182 '<div class="ftr"><span>oxide-sloc v{version}<\/span><span>Multi-Scan File Matrix<\/span><span>{project_label}<\/span><\/div>'+
11183 '<script>'+inlineJs+'<\/script><\/body><\/html>';
11184 }}
11185
11186 var htmlBtn=document.getElementById('mc-file-html-btn');
11187 if(htmlBtn)htmlBtn.addEventListener('click',function(){{
11188 var h=mcFileBuildHtml();
11189 var blob=new Blob([h],{{type:'text/html;charset=utf-8;'}});
11190 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
11191 a.download=mcExportName('files.html');a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
11192 }});
11193
11194 var pdfBtn=document.getElementById('mc-file-pdf-btn');
11195 if(pdfBtn)pdfBtn.addEventListener('click',function(){{
11196 window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('files.pdf'),button:pdfBtn}});
11197 }});
11198 }})();
11199
11200 // ── Inline scan charts (matching Scan Delta layout) ──────────────────────
11201 (function(){{
11202 var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
11203 // Deeper shade of each metric hue for "before"/Scan-1 bars — bold, not washed.
11204 var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
11205 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
11206 function fmt2(n){{return Number(n).toLocaleString();}}
11207 function px(n){{return Math.round(n);}}
11208 var _tt=document.getElementById('mc-ic-tt');
11209 function btt(l,v){{return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}}
11210 function addTT(el){{
11211 if(!el)return;
11212 el.addEventListener('mouseover',function(e){{
11213 var t=e.target.closest('[data-ttl]');
11214 if(t&&_tt){{
11215 var ttl=t.getAttribute('data-ttl');
11216 _tt.innerHTML='<strong>'+ttl+'</strong><br>'+t.getAttribute('data-ttv');
11217 _tt.style.display='block';mvTT(e);
11218 el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11219 el.querySelectorAll('[data-ttl]').forEach(function(x){{if(x.getAttribute('data-ttl')===ttl)x.style.filter='brightness(1.2)';}});
11220 }} else {{
11221 if(_tt)_tt.style.display='none';
11222 el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11223 }}
11224 }});
11225 el.addEventListener('mouseleave',function(){{
11226 if(_tt)_tt.style.display='none';
11227 el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11228 }});
11229 el.addEventListener('mousemove',function(e){{mvTT(e);}});
11230 }}
11231 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';}}
11232 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
11233 function buildCharts(){{
11234 if(N<2)return;
11235 var cs=getComputedStyle(document.body);
11236 function cv(name,fb){{var v=cs.getPropertyValue(name);return(v&&v.trim())||fb;}}
11237 var textCol=cv('--text','#43342d');
11238 var mutedCol=cv('--muted','#7b675b');
11239 var gFill=cv('--muted-2','#a08777');
11240 var LGY=cv('--line','#e6d0bf');
11241 var axisCol=cv('--line-strong','#d8bfad');
11242 var surf2col=cv('--surface-2','#f4ede4');
11243 var surfCol=cv('--surface','#fff8f0');
11244 var p0=POINTS[0],pLast=POINTS[N-1];
11245 var dark=document.body.classList.contains('dark-theme');
11246 var FADE=dark?'#524238':'#e6d0bf';
11247 var barBorder=dark?'rgba(255,255,255,0.40)':'rgba(0,0,0,0.62)';
11248 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;}}
11249 var c1mets=[
11250 {{l:'Code Lines',b:Number(p0.code),c:Number(pLast.code),bc:OXD,cc:OX}},
11251 {{l:'Files',b:Number(p0.files),c:Number(pLast.files),bc:GND,cc:GN}},
11252 {{l:'Comments',b:Number(p0.comments),c:Number(pLast.comments),bc:GDD,cc:GD}}
11253 ];
11254 var maxV1=niceMax(Math.max.apply(null,c1mets.map(function(m){{return Math.max(m.b,m.c);}}))||1);
11255 // Code Metrics chart — grows to fill the height its grid row settled to (the
11256 // Language Code Delta sibling usually drives that), so it never sits short at
11257 // the top of an over-tall cell. C1W is fixed; C1H scales with the cell.
11258 function drawC1(){{
11259 var C1W=620,C1H=200;
11260 var c1host=document.getElementById('mc-ic-c1');
11261 var c1card=c1host?c1host.closest('.ic-card'):null;
11262 if(c1host&&c1card&&c1host.clientWidth>0){{
11263 var avW=c1host.clientWidth;
11264 var availPx=(c1card.getBoundingClientRect().bottom-16)-c1host.getBoundingClientRect().top;
11265 var wantH=availPx*C1W/avW;
11266 if(wantH>C1H)C1H=wantH;
11267 }}
11268 var c1mt=40,c1mb=34,c1ml=58,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=54,c1gap=10;
11269 var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11270 for(var gi=1;gi<=4;gi++){{
11271 var gy=c1mt+c1ph*(1-gi/4),gv=maxV1*gi/4;
11272 c1+='<line x1="'+c1ml+'" y1="'+px(gy)+'" x2="'+(C1W-c1mr)+'" y2="'+px(gy)+'" stroke="'+LGY+'" stroke-width="0.5" stroke-dasharray="4,3"/>';
11273 c1+='<text x="'+(c1ml-6)+'" y="'+(px(gy)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt(gv)+'</text>';
11274 }}
11275 c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
11276 c1+='<text x="'+(c1ml-6)+'" y="'+px(c1mt+c1ph+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">0</text>';
11277 c1mets.forEach(function(m,i){{
11278 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
11279 var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
11280 c1+='<text x="'+cx+'" y="18" text-anchor="middle" font-family="'+FONT+'" font-size="13" font-weight="700" fill="'+textCol+'">'+esc(m.l)+'</text>';
11281 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;"/>';
11282 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>';
11283 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;"/>';
11284 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>';
11285 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>';
11286 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>';
11287 }});
11288 c1+='</svg>';
11289 return c1;
11290 }}
11291 // Chart 2: Delta by Metric (net delta first scan to last)
11292 var mets=[
11293 {{l:'Code Lines',v:Number(pLast.code)-Number(p0.code),mc:'#C45C10'}},
11294 {{l:'Files Analyzed',v:Number(pLast.files)-Number(p0.files),mc:'#2A6846'}},
11295 {{l:'Comment Lines',v:Number(pLast.comments)-Number(p0.comments),mc:GD}}
11296 ];
11297 var maxD=Math.max.apply(null,mets.map(function(m){{return Math.abs(m.v);}}));maxD=maxD||1;
11298 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;
11299 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11300 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
11301 mets.forEach(function(m,i){{
11302 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);
11303 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>';
11304 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;"/>';
11305 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>';}}
11306 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>';}}
11307 }});
11308 c2+='</svg>';
11309 // Chart 3: Language Code Delta (from FILES net total_code_delta per language)
11310 var lm={{}};
11311 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;}});
11312 var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}}).slice(0,12);
11313 function drawC3(){{
11314 if(!langs.length)return'';
11315 var maxLD=Math.max.apply(null,langs.map(function(l){{return Math.abs(lm[l].d);}}));maxLD=maxLD||1;
11316 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;
11317 var c3host=document.getElementById('mc-ic-c3');
11318 var c3card=document.getElementById('mc-ic-lang-card');
11319 var C3H=langs.length*30+24;
11320 if(c3host&&c3card&&c3host.clientWidth>0){{
11321 var avW=c3host.clientWidth;
11322 var availPx=(c3card.getBoundingClientRect().bottom-16)-c3host.getBoundingClientRect().top;
11323 var wantH=availPx*C3W/avW;
11324 if(wantH>C3H)C3H=wantH;
11325 }}
11326 var topPad=12,botPad=12,band=(C3H-topPad-botPad)/langs.length,barH=Math.min(22,band*0.5);
11327 var c3='<svg viewBox="0 0 '+C3W+' '+px(C3H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
11328 c3+='<line x1="'+cx3+'" y1="'+topPad+'" x2="'+cx3+'" y2="'+px(C3H-botPad)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
11329 langs.forEach(function(l,i){{
11330 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);
11331 c3+='<text x="'+(c3LW-7)+'" y="'+px(yc+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="'+textCol+'">'+esc(l)+'</text>';
11332 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"/>';
11333 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>';}}
11334 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>';}}
11335 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>';
11336 }});
11337 c3+='</svg>';
11338 return c3;
11339 }}
11340 // Chart 4: File Change Distribution (donut left, legend right, % on slices)
11341 var fm=0,fa=0,fr=0,fu=0;
11342 FILES.forEach(function(f){{if(f.s==='modified')fm++;else if(f.s==='added')fa++;else if(f.s==='removed')fr++;else fu++;}});
11343 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;}});
11344 var tot4=segs.reduce(function(a,s){{return a+s.v;}},0)||1;
11345 var C4W=380,C4H=210,cx4=104,cy4=105,Ro=80,Ri=50;
11346 function pctFill(c){{return c===FADE?textCol:'#ffffff';}}
11347 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;
11348 if(segs.length===1){{
11349 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"/>';
11350 c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="'+surfCol+'"/>';
11351 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>';
11352 }} else {{
11353 segs.forEach(function(s){{
11354 var sw=Math.min(s.v/tot4*2*Math.PI,2*Math.PI-0.001),a2=ang4+sw;
11355 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);
11356 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);
11357 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"/>';
11358 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>';}}
11359 ang4+=sw;
11360 }});
11361 }}
11362 c4+='<text x="'+cx4+'" y="'+(cy4-2)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="bold" fill="'+textCol+'">'+fmt2(tot4)+'</text>';
11363 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">total files</text>';
11364 var legX=212,legRowH=26,legBlockH=segs.length*legRowH,legStartY=cy4-legBlockH/2+legRowH/2;
11365 segs.forEach(function(s,i){{
11366 var ly=legStartY+i*legRowH,pct=px(s.v/tot4*100);
11367 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;"/>';
11368 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>';
11369 c4+='<text x="'+(legX+20)+'" y="'+px(ly+15)+'" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt2(s.v)+' files • '+pct+'%</text>';
11370 }});
11371 c4+='</svg>';
11372 // Inject the fixed-size siblings first, then size Code Metrics (c1) and
11373 // Language Code Delta (c3) to fill the shared grid-row height. c1 is drawn
11374 // once at natural height to seed the row, then both are filled to the row the
11375 // grid settled to, so neither sits short at the top of an over-tall cell.
11376 var lc=document.getElementById('mc-ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
11377 var e2=document.getElementById('mc-ic-c2');if(e2)e2.innerHTML=c2;
11378 var e4=document.getElementById('mc-ic-c4');if(e4)e4.innerHTML=c4;
11379 var e1=document.getElementById('mc-ic-c1');if(e1)e1.innerHTML=drawC1();
11380 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>';
11381 if(e1)e1.innerHTML=drawC1();
11382 }}
11383 buildCharts();
11384 renderInlineCharts=buildCharts;
11385 ['mc-ic-c1','mc-ic-c2','mc-ic-c3','mc-ic-c4'].forEach(function(id){{var el=document.getElementById(id);if(el)addTT(el);}});
11386 (function(){{
11387 var ov=document.getElementById('ic-svg-modal-ov');
11388 var body=document.getElementById('ic-svg-modal-body');
11389 var ttl=document.getElementById('ic-svg-modal-title');
11390 var closeBtn=document.getElementById('ic-svg-modal-close');
11391 if(!ov||!body)return;
11392 function close(){{ov.classList.remove('open');body.innerHTML='';}}
11393 function open(srcId,title){{
11394 var src=document.getElementById(srcId);if(!src)return;
11395 ttl.textContent=title||'';
11396 var card=src.closest('.ic-card');
11397 var legHtml='';
11398 if(card){{var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg" style="margin-bottom:14px;">'+leg.innerHTML+'</div>';}}
11399 body.innerHTML=legHtml+src.innerHTML;
11400 var svg=body.querySelector('svg');
11401 if(svg){{svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}}
11402 addTT(body);
11403 ov.classList.add('open');
11404 }}
11405 document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){{
11406 btn.addEventListener('click',function(){{open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));}});
11407 }});
11408 if(closeBtn)closeBtn.addEventListener('click',close);
11409 ov.addEventListener('click',function(e){{if(e.target===ov)close();}});
11410 document.addEventListener('keydown',function(e){{if(e.key==='Escape'&&ov.classList.contains('open'))close();}});
11411 }})();
11412
11413 // HTML legend hover → highlight matching SVG bars within the SAME card only
11414 document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){{
11415 var metric=leg.getAttribute('data-highlight');
11416 var parentCard=leg.closest('.ic-card');
11417 var chartEl=parentCard?parentCard.querySelector('[id]'):null;
11418 if(!chartEl)return;
11419 leg.addEventListener('mouseenter',function(){{
11420 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{
11421 if(x.getAttribute('data-ttl').indexOf(metric)===0){{
11422 x.style.filter='brightness(1.35) drop-shadow(0 2px 8px rgba(0,0,0,0.28))';
11423 x.style.opacity='1';
11424 }} else {{
11425 x.style.opacity='0.28';
11426 }}
11427 }});
11428 }});
11429 leg.addEventListener('mouseleave',function(){{
11430 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
11431 }});
11432 }});
11433 // Author handles
11434 document.querySelectorAll('.cmp-author-val').forEach(function(el){{var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');}});
11435
11436 // ── Export helpers ────────────────────────────────────────────────────────
11437 // Fetch one image from the server and return a data-URI Promise
11438 function mcFetchUri(path){{
11439 return fetch(path).then(function(r){{return r.blob();}}).then(function(b){{
11440 return new Promise(function(res){{
11441 var rd=new FileReader();rd.onload=function(){{res(rd.result);}};rd.onerror=function(){{res('');}};rd.readAsDataURL(b);
11442 }});
11443 }}).catch(function(){{return '';}});
11444 }}
11445 // Replace /images/… src attrs in html with base64 data-URIs (async, callback)
11446 function mcInlineImgs(html,cb){{
11447 var paths=[],seen={{}};
11448 html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){{if(!seen[p]){{seen[p]=1;paths.push(p);}}return _;}});
11449 if(!paths.length){{cb(html);return;}}
11450 Promise.all(paths.map(function(p){{return mcFetchUri(p).then(function(u){{return{{p:p,u:u}};}}); }}))
11451 .then(function(rs){{rs.forEach(function(r){{if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');}});cb(html);}})
11452 .catch(function(){{cb(html);}});
11453 }}
11454 // Capture full-page HTML with all table rows visible
11455 function mcRawHtml(pdfMode){{
11456 if(pdfMode)document.body.classList.add('pdf-mode');
11457 var s=perPage,p=currentPage;perPage=FILES.length||999999;currentPage=1;renderFilePage();
11458 var html=document.documentElement.outerHTML;
11459 perPage=s;currentPage=p;renderFilePage();
11460 if(pdfMode)document.body.classList.remove('pdf-mode');
11461 return html;
11462 }}
11463
11464 // HTML export (full page with inlined images)
11465 function mcDoHtml(btn,fname){{
11466 var orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
11467 mcInlineImgs(mcRawHtml(false),function(html){{
11468 var blob=new Blob([html],{{type:'text/html;charset=utf-8;'}});
11469 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
11470 a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
11471 btn.disabled=false;btn.innerHTML=orig;
11472 }});
11473 }}
11474 // PDF export — comprehensive document-style report: full numbers, all sections
11475 function mcBuildPdfHtml(){{
11476 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
11477 function full(n){{if(n==null||n===''||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
11478 function dStr(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
11479 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>';}}
11480 var tz;try{{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{tz='America/Los_Angeles';}}
11481 var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
11482 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)));}}
11483 var commitsList=POINTS.map(function(pt,i){{return esc(ptRef(pt,i));}}).join(', ');
11484 var p0=N>0?POINTS[0]:null,pLast=N>0?POINTS[N-1]:null;
11485 var codeDelta=(p0&&pLast)?Number(pLast.code)-Number(p0.code):null;
11486 // Header/footer flow in document order (NOT position:fixed) — a fixed
11487 // header repeats every printed page in Chromium and overlaps the content
11488 // below it, swallowing the first rows of pages 2+ and clipping the cards
11489 // on page 1. The table <thead> repeats per page natively, so every row
11490 // stays visible.
11491 var css='body{{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}}'+
11492 '.pdf-header{{-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11493 '.pdf-footer{{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
11494 '.page-hdr{{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}}'+
11495 '.ph-brand{{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}}'+
11496 '.ph-brand em{{color:#c45c10;font-style:normal;}}'+
11497 '.ph-title{{font-size:14px;font-weight:600;color:#555;}}'+
11498 '.ph-date{{font-size:11px;color:#888;text-align:right;white-space:nowrap;}}'+
11499 '.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;}}'+
11500 '.ib-name{{font-size:13px;font-weight:800;color:#fff;}}'+
11501 '.ib-right{{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}}'+
11502 '.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;}}'+
11503 '.body{{padding:12px 18px 0;}}'+
11504 '.sg{{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}}'+
11505 '.sc{{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}}'+
11506 '.sv{{font-size:18px;font-weight:900;color:#c45c10;}}'+
11507 '.sl{{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}}'+
11508 '.sec{{margin-bottom:10px;}}'+
11509 '.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;}}'+
11510 'table{{width:100%;border-collapse:collapse;font-size:11px;}}'+
11511 '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;}}'+
11512 'td{{border-bottom:1px solid #eee;padding:3px 7px;vertical-align:middle;}}'+
11513 'tr:nth-child(even) td{{background:#faf8f6;}}';
11514 // ── Metric Progression ────────────────────────────────────────────────
11515 var hasTests=POINTS.some(function(pt){{return pt.tests!=null&&Number(pt.tests)>0;}});
11516 var hasCov=POINTS.some(function(pt){{return pt.cov!=null;}});
11517 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>';
11518 if(hasTests)progHdr+='<th style="text-align:right">Tests</th>';
11519 if(hasCov)progHdr+='<th style="text-align:right">Coverage</th>';
11520 var progRows=POINTS.map(function(pt,i){{
11521 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)));
11522 var r='<tr><td style="text-align:center;font-weight:700">'+(i+1)+'</td><td>'+esc(lbl)+'</td>'+
11523 '<td style="text-align:right">'+full(pt.code)+'</td>'+
11524 '<td style="text-align:right">'+full(pt.comments)+'</td>'+
11525 '<td style="text-align:right">'+full(pt.blank)+'</td>'+
11526 '<td style="text-align:right">'+full(pt.files)+'</td>';
11527 if(hasTests)r+='<td style="text-align:right">'+(pt.tests!=null&&Number(pt.tests)>0?full(pt.tests):'—')+'</td>';
11528 if(hasCov)r+='<td style="text-align:right">'+(pt.cov!=null?Number(pt.cov).toFixed(1)+'%':'—')+'</td>';
11529 return r+'</tr>';
11530 }}).join('');
11531 // ── Scan-to-scan changes ──────────────────────────────────────────────
11532 var deltaRows=N>1?POINTS.slice(1).map(function(pt,i){{
11533 var prev=POINTS[i];
11534 var cd=Number(pt.code)-Number(prev.code),cm=Number(pt.comments)-Number(prev.comments);
11535 var bl=Number(pt.blank)-Number(prev.blank),fd=Number(pt.files)-Number(prev.files);
11536 return '<tr><td style="font-weight:700;white-space:nowrap">'+esc(ptRef(prev,i))+' \u2192 '+esc(ptRef(pt,i+1))+'</td>'+
11537 '<td style="text-align:right">'+dHtml(cd)+'</td>'+
11538 '<td style="text-align:right">'+dHtml(cm)+'</td>'+
11539 '<td style="text-align:right">'+dHtml(bl)+'</td>'+
11540 '<td style="text-align:right">'+dHtml(fd)+'</td></tr>';
11541 }}).join(''):'';
11542 // ── File matrix (top 50 by |total delta|) ────────────────────────────
11543 var fmSection='';
11544 if(FILES&&FILES.length){{
11545 // Hard cap on per-scan columns so the table never overflows the page width.
11546 var MAXC=6;var startIdx=N>MAXC?N-MAXC:0;
11547 var topFiles=FILES.slice().sort(function(a,b){{return Math.abs(Number(b.t))-Math.abs(Number(a.t));}});
11548 var fmHdr='<th>File</th><th>Language</th><th>Status</th>';
11549 for(var fi=startIdx;fi<N;fi++)fmHdr+='<th style="text-align:right">Scan '+(fi+1)+'</th>';
11550 fmHdr+='<th style="text-align:right">Total \u0394</th>';
11551 var fmRows=topFiles.map(function(f){{
11552 var ss=f.s==='added'?'style="color:#2a6846;font-weight:700"':f.s==='removed'?'style="color:#b23030;font-weight:700"':'';
11553 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>';
11554 cols+='<td style="text-align:right">'+dHtml(Number(f.t))+'</td>';
11555 var sp=f.p.length>55?'\u2026'+f.p.slice(-53):f.p;
11556 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>';
11557 }}).join('');
11558 var colNote=N>MAXC?' (latest '+MAXC+' scans shown)':'';
11559 fmSection='<div class="sec"><p class="sh">File Matrix \u2014 All '+FILES.length+' Files'+colNote+'</p>'+
11560 '<table><thead><tr>'+fmHdr+'</tr></thead><tbody>'+fmRows+'</tbody></table></div>';
11561 }}
11562 return '<!DOCTYPE html><html><head><meta charset="utf-8">'+
11563 '<title>OxideSLOC \u2014 Multi-Scan Timeline</title><style>'+css+'</style></head><body>'+
11564 '<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>'+
11565
11566 '<div class="body">'+
11567 '<div class="sg">'+
11568 (pLast?'<div class="sc"><div class="sv">'+full(pLast.code)+'</div><div class="sl">Latest Code Lines</div></div>':
11569 '<div class="sc"><div class="sv">—</div><div class="sl">Latest Code Lines</div></div>')+
11570 (pLast?'<div class="sc"><div class="sv">'+full(pLast.files)+'</div><div class="sl">Latest Files</div></div>':
11571 '<div class="sc"><div class="sv">—</div><div class="sl">Latest Files</div></div>')+
11572 (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>':
11573 '<div class="sc"><div class="sv">—</div><div class="sl">Net Code Change</div></div>')+
11574 '<div class="sc"><div class="sv" style="color:#111">{n}</div><div class="sl">Scans Compared</div></div>'+
11575 '</div>'+
11576 '<div class="sec"><p class="sh">Metric Progression</p>'+
11577 '<table><thead><tr>'+progHdr+'</tr></thead><tbody>'+progRows+'</tbody></table></div>'+
11578 (N>1?'<div class="sec"><p class="sh">Scan-to-Scan Changes</p>'+
11579 '<table><thead><tr><th style="text-align:center">Scans</th>'+
11580 '<th style="text-align:right">Code \u0394</th><th style="text-align:right">Comments \u0394</th>'+
11581 '<th style="text-align:right">Blank \u0394</th><th style="text-align:right">Files \u0394</th>'+
11582 '</tr></thead><tbody>'+deltaRows+'</tbody></table></div>':'')+
11583 fmSection+
11584 '</div>'+
11585 '<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>'+
11586 '</body></html>';
11587 }}
11588 function mcDoPdf(btn){{
11589 window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('pdf'),button:btn}});
11590 }}
11591
11592 var mcHtmlBtn=document.getElementById('mc-export-html-btn');
11593 if(mcHtmlBtn)mcHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcHtmlBtn,mcExportName('html'));}});
11594 var mcTopHtmlBtn=document.getElementById('mc-top-export-html-btn');
11595 if(mcTopHtmlBtn)mcTopHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcTopHtmlBtn,mcExportName('html'));}});
11596 var mcPdfBtn=document.getElementById('mc-export-pdf-btn');
11597 if(mcPdfBtn)mcPdfBtn.addEventListener('click',function(){{mcDoPdf(mcPdfBtn);}});
11598 var mcTopPdfBtn=document.getElementById('mc-top-export-pdf-btn');
11599 if(mcTopPdfBtn)mcTopPdfBtn.addEventListener('click',function(){{mcDoPdf(mcTopPdfBtn);}});
11600 if(location.protocol==='file:'){{
11601 [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';}}}} );
11602 [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';}}}} );
11603 }}
11604 }})();
11605 // ── Scan card modal — document-level click delegation (no timing/parse-order deps) ──
11606 (function(){{
11607 function $(id){{return document.getElementById(id);}}
11608 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
11609 function full(n){{if(n==null||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
11610 function dS(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
11611 function dSt(v){{return Number(v)>0?'color:#2a6846;font-weight:700':Number(v)<0?'color:#b23030;font-weight:700':'';}}
11612 function openModal(idx){{
11613 var ov=$('mc-modal-overlay');if(!ov)return;
11614 var titleEl=$('mc-modal-title'),subEl=$('mc-modal-sub'),bodyEl=$('mc-modal-body');
11615 if(idx<0||idx>=N)return;
11616 var pt=POINTS[idx];
11617 titleEl.textContent='Scan '+(idx+1);
11618 var lbl=pt.tags||(pt.branch?(pt.commit?pt.branch+' @ '+pt.commit:pt.branch):(pt.commit||'\u2014'));
11619 subEl.textContent=lbl;
11620 var sHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Metrics</div><div class="mc-modal-stats">'+
11621 '<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>'+
11622 '<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>'+
11623 '<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>'+
11624 '<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>'+
11625 (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>':'')+
11626 (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>':'')+
11627 '</div></div>';
11628 var iHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Scan Info</div>'+
11629 (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>':'')+
11630 (pt.branch?'<div class="mc-modal-row"><span class="mc-modal-key">Branch</span><span class="mc-modal-val">'+esc(pt.branch)+'</span></div>':'')+
11631 (pt.tags?'<div class="mc-modal-row"><span class="mc-modal-key">Tags</span><span class="mc-modal-val">'+esc(pt.tags)+'</span></div>':'')+
11632 (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>':'')+
11633 (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>':'')+
11634 (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>':'')+
11635 (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>':'')+
11636 '<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>'+
11637 '</div>';
11638 var dHtml='';
11639 if(idx>0){{
11640 var prev=POINTS[idx-1];
11641 var cd=Number(pt.code)-Number(prev.code),fd=Number(pt.files)-Number(prev.files),cm=Number(pt.comments)-Number(prev.comments);
11642 dHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Change vs Scan '+idx+'</div><div class="mc-modal-stats">'+
11643 '<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>'+
11644 '<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>'+
11645 '<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>'+
11646 '</div></div>';
11647 }}
11648 bodyEl.innerHTML=sHtml+iHtml+dHtml;
11649 ov.classList.add('open');document.body.style.overflow='hidden';
11650 }}
11651 function closeModal(){{var ov=$('mc-modal-overlay');if(ov)ov.classList.remove('open');document.body.style.overflow='';}}
11652 // Delegated click: robust to parse order, re-renders, and missing-at-attach elements.
11653 document.addEventListener('click',function(e){{
11654 if(!e.target||!e.target.closest)return;
11655 if(e.target.closest('#mc-modal-close')){{closeModal();return;}}
11656 if(e.target.id==='mc-modal-overlay'){{closeModal();return;}}
11657 var card=e.target.closest('.mc-card');
11658 if(!card)return;
11659 if(e.target.closest('a'))return;
11660 var cards=Array.prototype.slice.call(document.querySelectorAll('.mc-card'));
11661 var i=cards.indexOf(card);
11662 if(i>=0)openModal(i);
11663 }});
11664 document.addEventListener('keydown',function(e){{if(e.key==='Escape')closeModal();}});
11665 // Styled hover description for the metric boxes (fixed tooltip, never clipped by the modal scroll area).
11666 var statTip=null;
11667 document.addEventListener('mousemove',function(e){{
11668 var box=(e.target&&e.target.closest)?e.target.closest('.mc-modal-stat[data-tip]'):null;
11669 if(!box){{if(statTip)statTip.style.display='none';return;}}
11670 if(!statTip){{statTip=document.createElement('div');statTip.id='mc-stat-tt';document.body.appendChild(statTip);}}
11671 var tip=box.getAttribute('data-tip')||'';
11672 if(statTip.textContent!==tip)statTip.textContent=tip;
11673 statTip.style.display='block';
11674 var w=statTip.offsetWidth,h=statTip.offsetHeight,x=e.clientX+14,y=e.clientY+16;
11675 if(x+w>window.innerWidth-8)x=e.clientX-w-14;
11676 if(y+h>window.innerHeight-8)y=e.clientY-h-16;
11677 statTip.style.left=(x<8?8:x)+'px';statTip.style.top=(y<8?8:y)+'px';
11678 }});
11679 (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');}})();
11680 }})();
11681 }})();
11682 </script>
11683 <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]';
11684 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;}}
11685 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>
11686 <!-- Scan card detail modal -->
11687 <div class="mc-modal-overlay" id="mc-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="mc-modal-title">
11688 <div class="mc-modal" id="mc-modal">
11689 <div class="mc-modal-head">
11690 <div><div class="mc-modal-title" id="mc-modal-title">Scan</div><div class="mc-modal-sub" id="mc-modal-sub"></div></div>
11691 <button class="mc-modal-close" id="mc-modal-close" aria-label="Close">✕</button>
11692 </div>
11693 <div class="mc-modal-body" id="mc-modal-body"></div>
11694 </div>
11695 </div>
11696 {toast_assets}
11697</body>
11698</html>"#,
11699 project_label = html_escape(project_label),
11700 n = n,
11701 scan_strip = scan_strip,
11702 mc_strip_class = mc_strip_class,
11703 metrics_thead = metrics_thead,
11704 metrics_tbody = metrics_tbody,
11705 file_col_headers = file_col_headers,
11706 total_files = total_files,
11707 files_modified = files_modified,
11708 files_added = files_added,
11709 files_removed = files_removed,
11710 files_unchanged = files_unchanged,
11711 points_json = points_json,
11712 file_matrix_json = file_matrix_json,
11713 nav_compare_active = nav_compare_active,
11714 version = version,
11715 csp_nonce = csp_nonce,
11716 scope_bar_html = scope_bar_html,
11717 scope_label = scope_label,
11718 loading_overlay = loading_overlay_block(csp_nonce, "Loading comparison"),
11719 )
11720}
11721
11722#[allow(clippy::too_many_lines)] async fn trend_report_handler(
11730 State(state): State<AppState>,
11731 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
11732) -> Response {
11733 auto_scan_watched_dirs(&state).await;
11734
11735 let watched_dirs_list: Vec<String> = {
11736 let wd = state.watched_dirs.lock().await;
11737 wd.dirs.iter().map(|p| p.display().to_string()).collect()
11738 };
11739
11740 let roots: Vec<String> = {
11742 let reg = state.registry.lock().await;
11743 let mut seen = std::collections::BTreeSet::new();
11744 reg.entries
11745 .iter()
11746 .flat_map(|e| e.input_roots.iter().cloned())
11747 .filter(|r| seen.insert(r.clone()))
11748 .collect()
11749 };
11750
11751 let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
11752 let nonce = &csp_nonce;
11753 let version = env!("CARGO_PKG_VERSION");
11754 let toast_assets = sloc_toast_assets(nonce);
11755
11756 let watched_dirs_html: String = if state.server_mode {
11760 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()
11761 } else {
11762 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
11763 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
11764 .to_string()
11765 } else {
11766 watched_dirs_list
11767 .iter()
11768 .fold(String::new(), |mut s, d| {
11769 use std::fmt::Write as _;
11770 let escaped =
11771 d.replace('&', "&").replace('"', """).replace('<', "<");
11772 write!(
11773 s,
11774 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>"#
11775 ).expect("write to String is infallible");
11776 s
11777 })
11778 };
11779 format!(
11780 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>"#
11781 )
11782 };
11783
11784 let html = format!(
11785 r##"<!doctype html>
11786<html lang="en">
11787<head>
11788 <meta charset="utf-8" />
11789 <meta name="viewport" content="width=device-width, initial-scale=1" />
11790 <title>OxideSLOC | Trend Reports</title>
11791 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
11792 <style nonce="{nonce}">
11793 :root {{
11794 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
11795 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
11796 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
11797 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
11798 --info-bg:#eef3ff; --info-text:#4467d8;
11799 }}
11800 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
11801 *{{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;}}
11802 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
11803 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
11804 .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;}}
11805 @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));}}}}
11806 .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);}}
11807 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
11808 .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));}}
11809 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
11810 .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;}}
11811 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
11812 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
11813 @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; }} }}
11814 .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;}}
11815 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
11816 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
11817 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
11818 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
11819 .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;}}
11820 .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;}}
11821 .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;}}
11822 .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;}}
11823 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
11824 .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);}}
11825 .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;}}
11826 .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;}}
11827 .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;}}
11828 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
11829 .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;}}
11830 .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);}}
11831 .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;}}
11832 .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;}}
11833 .tz-select:focus{{border-color:var(--oxide);}}
11834 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
11835 @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
11836 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
11837 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
11838 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
11839 .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
11840 .trend-title-block{{flex:1;min-width:0;}}
11841 .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;}}
11842 .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
11843 .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;}}
11844 .chart-select:focus{{border-color:var(--accent);}}
11845 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
11846 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
11847 .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);}}
11848 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
11849 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
11850 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
11851 .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);}}
11852 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
11853 .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
11854 .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;}}
11855 .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
11856 body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
11857 .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
11858 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
11859 .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;}}
11860 .tr-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
11861 .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;}}
11862 .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);}}
11863 .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;}}
11864 .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
11865 .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
11866 .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);}}
11867 .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
11868 .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;}}
11869 .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;}}
11870 .data-table tr:last-child td{{border-bottom:none;}}
11871 .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
11872 .num{{text-align:right;font-variant-numeric:tabular-nums;}}
11873 .table-wrap{{width:100%;overflow-x:auto;}}
11874 .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
11875 .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
11876 .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
11877 .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
11878 .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
11879 .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
11880 .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;}}
11881 .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;}}
11882 .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
11883 .pagination-info{{font-size:13px;color:var(--muted);}}
11884 .pagination-btns{{display:flex;gap:6px;}}
11885 .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;}}
11886 .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;}}
11887 #scan-history-table col:nth-child(1){{width:155px;}}
11888 #scan-history-table col:nth-child(2){{width:240px;}}
11889 #scan-history-table col:nth-child(3){{width:82px;}}
11890 #scan-history-table col:nth-child(4){{width:82px;}}
11891 #scan-history-table col:nth-child(5){{width:90px;}}
11892 #scan-history-table col:nth-child(6){{width:90px;}}
11893 #scan-history-table col:nth-child(7){{width:88px;}}
11894 #scan-history-table col:nth-child(8){{width:150px;}}
11895 #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
11896 .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;}}
11897 .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;}}
11898 .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
11899 .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
11900 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
11901 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
11902 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
11903 .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;}}
11904 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
11905 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
11906 .watched-chip-rm:hover{{color:var(--oxide);}}
11907 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
11908 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
11909 .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
11910 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
11911 .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
11912 a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
11913 a.run-link:hover{{text-decoration:underline;}}
11914 .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);}}
11915 .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);}}
11916 body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
11917 .metric-num{{font-weight:700;color:var(--text);}}
11918 .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
11919 .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;}}
11920 .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
11921 .btn.primary:hover{{opacity:.9;}}
11922 .rpt-btn{{min-width:58px;justify-content:center;}}
11923 .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
11924 .report-cell{{overflow:visible!important;white-space:normal!important;}}
11925 .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
11926 .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
11927 .submod-details summary::-webkit-details-marker{{display:none;}}
11928 .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
11929 .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;}}
11930 .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
11931 body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
11932 .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
11933 .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;}}
11934 .export-btn:hover{{background:var(--line);}}
11935 .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
11936 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
11937 .site-footer a{{color:var(--muted);}}
11938 .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;}}
11939 .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;}}
11940 @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
11941 /* Modal system (Retention Policy / Clean-up) */
11942 .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;}}
11943 @keyframes tr-fade{{from{{opacity:0;}}to{{opacity:1;}}}}
11944 .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);}}
11945 .tr-modal{{background:rgba(255,255,255,0.90);}}
11946 body.dark-theme .tr-modal{{background:rgba(38,28,23,0.90);}}
11947 @keyframes tr-pop{{from{{transform:translateY(14px) scale(.97);opacity:0;}}to{{transform:none;opacity:1;}}}}
11948 .tr-modal-head{{display:flex;align-items:center;gap:14px;padding:24px 30px 18px;border-bottom:1px solid var(--line);}}
11949 .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);}}
11950 .tr-modal-icon svg{{width:23px;height:23px;stroke:#fff;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}}
11951 .tr-modal-icon.danger{{background:linear-gradient(135deg,#d65a5a,#b23030);box-shadow:0 4px 12px rgba(178,48,48,0.32);}}
11952 .tr-modal-title{{font-size:21px;font-weight:900;letter-spacing:-.01em;color:var(--text);margin:0;line-height:1.15;}}
11953 .tr-modal-sub{{font-size:12.5px;color:var(--muted);margin:2px 0 0;line-height:1.4;}}
11954 .tr-modal-body{{padding:22px 30px;}}
11955 .tr-modal-foot{{display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;padding:18px 30px 24px;border-top:1px solid var(--line);}}
11956 .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;}}
11957 .tr-btn:hover{{transform:translateY(-1px);}}
11958 .tr-btn:active{{transform:translateY(0);}}
11959 .tr-btn:disabled{{opacity:.55;cursor:not-allowed;transform:none;}}
11960 .tr-btn svg{{width:15px;height:15px;stroke:currentColor;fill:none;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;}}
11961 .tr-btn-primary{{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;box-shadow:0 4px 14px rgba(184,80,40,0.28);}}
11962 .tr-btn-primary:hover{{box-shadow:0 7px 20px rgba(184,80,40,0.38);}}
11963 .tr-btn-secondary{{background:var(--surface-2);color:var(--text);border-color:var(--line-strong);}}
11964 .tr-btn-secondary:hover{{background:var(--line);}}
11965 .tr-btn-danger{{background:linear-gradient(135deg,#d65a5a,#b23030);color:#fff;box-shadow:0 4px 14px rgba(178,48,48,0.28);}}
11966 .tr-btn-danger:hover{{box-shadow:0 7px 20px rgba(178,48,48,0.4);}}
11967 </style>
11968</head>
11969<body>
11970 <div class="background-watermarks" aria-hidden="true">
11971 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11972 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11973 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11974 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11975 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11976 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
11977 </div>
11978 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
11979 <div class="top-nav">
11980 <div class="top-nav-inner">
11981 <a class="brand" href="/">
11982 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
11983 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
11984 </a>
11985 <div class="nav-right">
11986 <a class="nav-pill" href="/">Home</a>
11987 <div class="nav-dropdown">
11988 <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>
11989 <div class="nav-dropdown-menu">
11990 <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>
11991 </div>
11992 </div>
11993 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
11994 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
11995 <div class="nav-dropdown">
11996 <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>
11997 <div class="nav-dropdown-menu">
11998 <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>
11999 </div>
12000 </div>
12001 <div class="server-status-wrap" id="server-status-wrap">
12002 <div class="nav-pill server-online-pill" id="server-status-pill">
12003 <span class="status-dot" id="status-dot"></span>
12004 <span id="server-status-label">Server</span>
12005 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
12006 </div>
12007 <div class="server-status-tip">
12008 OxideSLOC is running — accessible on your network.
12009 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
12010 </div>
12011 </div>
12012 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
12013 <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>
12014 </button>
12015 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
12016 <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>
12017 <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>
12018 </button>
12019 </div>
12020 </div>
12021 </div>
12022
12023 <div class="page">
12024 {watched_dirs_html}
12025 <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
12026 <div class="scan-overlay-card">
12027 <div class="scan-spinner"></div>
12028 <div class="scan-overlay-text">Scanning folder…</div>
12029 <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
12030 </div>
12031 </div>
12032 <style>
12033 .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);}}
12034 .scan-overlay.active{{display:flex;}}
12035 .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;}}
12036 .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;}}
12037 @keyframes scanSpin{{to{{transform:rotate(360deg);}}}}
12038 .scan-overlay-text{{font-size:15px;font-weight:800;color:var(--text);}}
12039 .scan-overlay-sub{{font-size:12px;color:var(--muted);line-height:1.5;}}
12040 </style>
12041 <div class="summary-strip" id="trend-stats"></div>
12042 <div class="panel">
12043 <div class="trend-header">
12044 <div class="trend-title-block">
12045 <h1>Trend Reports</h1>
12046 <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>
12047 <span class="chart-hint-inline">
12048 <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>
12049 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
12050 </span>
12051 </div>
12052 <div class="chart-actions">
12053 <button type="button" class="export-btn" id="retention-policy-btn" title="Configure automatic cleanup of old scan runs">
12054 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
12055 Retention Policy
12056 </button>
12057 <button type="button" class="export-btn" id="cleanup-runs-btn" title="Delete scans older than a chosen number of days">
12058 <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>
12059 Clean up old runs
12060 </button>
12061 <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
12062 <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>
12063 Export Excel
12064 </button>
12065 <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
12066 <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>
12067 Export PNG
12068 </button>
12069 <button type="button" class="export-btn" id="export-pdf-btn" title="Open a print-ready PDF report (chart + summary + table)">
12070 <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>
12071 Export PDF
12072 </button>
12073 </div>
12074 </div>
12075
12076 <div class="controls-centered">
12077 <label>Project Root:
12078 <select class="chart-select" id="root-sel">
12079 <option value="">All projects</option>
12080 </select>
12081 </label>
12082 <label>Y Metric:
12083 <select class="chart-select" id="y-sel">
12084 <option value="code_lines">Code Lines</option>
12085 <option value="comment_lines">Comment Lines</option>
12086 <option value="blank_lines">Blank Lines</option>
12087 <option value="physical_lines">Physical Lines</option>
12088 <option value="files_analyzed">Files Analyzed</option>
12089 </select>
12090 </label>
12091 <label>X Axis:
12092 <select class="chart-select" id="x-sel">
12093 <option value="time">By Time</option>
12094 <option value="commit" selected>By Commit</option>
12095 <option value="release">By Release</option>
12096 <option value="tag">Tagged Commits</option>
12097 </select>
12098 </label>
12099 <label id="submodule-label" style="display:none;">Submodule:
12100 <select class="chart-select" id="sub-sel">
12101 <option value="">All (project total)</option>
12102 </select>
12103 </label>
12104 <label>Chart Size:
12105 <select class="chart-select" id="scale-sel">
12106 <option value="0.75">Compact</option>
12107 <option value="1.2" selected>Normal</option>
12108 <option value="1.38">Large</option>
12109 </select>
12110 </label>
12111 <button class="tr-expand-btn" id="tr-chart-fv-btn">⤢ Full View</button>
12112 </div>
12113
12114 <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history…</div></div>
12115 <div id="data-table-wrap" style="overflow-x:auto;"></div>
12116 </div>
12117 </div>
12118
12119 <script nonce="{nonce}">
12120 (function() {{
12121 // Theme persistence
12122 var b = document.body;
12123 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
12124 var tgl = document.getElementById('theme-toggle');
12125 if (tgl) tgl.addEventListener('click', function() {{
12126 var d = b.classList.toggle('dark-theme');
12127 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
12128 }});
12129
12130 // Watermark randomizer
12131 (function() {{
12132 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
12133 if (!wms.length) return;
12134 var placed = [];
12135 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;}}
12136 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];}}
12137 var half=Math.floor(wms.length/2);
12138 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;}});
12139 }})();
12140
12141 // Code particles
12142 (function() {{
12143 var container = document.getElementById('code-particles');
12144 if (!container) return;
12145 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'];
12146 for (var i = 0; i < 38; i++) {{
12147 (function(idx) {{
12148 var el = document.createElement('span');
12149 el.className = 'code-particle';
12150 el.textContent = snippets[idx % snippets.length];
12151 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
12152 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
12153 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
12154 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';
12155 container.appendChild(el);
12156 }})(i);
12157 }}
12158 }})();
12159
12160 // Watched folder picker
12161 (function(){{
12162 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');}};
12163 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);
12164 }})();
12165 (function() {{
12166 var btn = document.getElementById('add-watched-btn');
12167 if (!btn) return;
12168 btn.addEventListener('click', function() {{
12169 fetch('/pick-directory?kind=reports')
12170 .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
12171 .then(function(data) {{
12172 if (!data.cancelled && data.selected_path) {{
12173 var form = document.createElement('form');
12174 form.method = 'POST';
12175 form.action = '/watched-dirs/add';
12176 var ri = document.createElement('input');
12177 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
12178 var fi = document.createElement('input');
12179 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
12180 form.appendChild(ri); form.appendChild(fi);
12181 document.body.appendChild(form);
12182 if (window.__scanOverlay) window.__scanOverlay();
12183 form.submit();
12184 }}
12185 }})
12186 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
12187 }});
12188 }})();
12189
12190 // Settings / color-scheme modal
12191 (function() {{
12192 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'}}];
12193 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);}});}}
12194 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
12195 var btn=document.getElementById('settings-btn');if(!btn)return;
12196 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
12197 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>';
12198 document.body.appendChild(m);
12199 var g=document.getElementById('scheme-grid');
12200 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);}});
12201 var cl=document.getElementById('settings-close');
12202 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);}});}})();
12203 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');}});
12204 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
12205 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
12206 }})();
12207 }})();
12208
12209 var ROOTS = {roots_json};
12210 var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
12211 var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
12212 var allData = [];
12213
12214 // Populate root selector
12215 var rootSel = document.getElementById('root-sel');
12216 ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
12217
12218 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();}}
12219 function fmtFull(n){{return Number(n).toLocaleString();}}
12220 function esc(s){{ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }}
12221
12222 // Tooltip
12223 var tt = document.createElement('div');
12224 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);';
12225 document.body.appendChild(tt);
12226 function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
12227 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';}}
12228 function hideTT(){{tt.style.display='none';}}
12229 window.addEventListener('blur',function(){{hideTT();}});
12230 document.addEventListener('visibilitychange',function(){{if(document.hidden)hideTT();}});
12231
12232 function statExact(compact, full){{
12233 return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
12234 }}
12235 function statVal(n){{
12236 var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
12237 }}
12238
12239 function updateStats(data){{
12240 var statsEl=document.getElementById('trend-stats');
12241 if(!statsEl)return;
12242 if(!data||!data.length){{statsEl.innerHTML='';return;}}
12243 var yKey=document.getElementById('y-sel').value;
12244 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
12245 var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12246 var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
12247 var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
12248 var absDelta=Math.abs(delta);
12249 var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
12250 var deltaExact=statExact(deltaCompact,deltaFull);
12251 var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
12252 statsEl.innerHTML=
12253 '<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>'+
12254 '<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>'+
12255 '<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>'+
12256 '<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>';
12257 }}
12258
12259 var subSel = document.getElementById('sub-sel');
12260 var subLabel = document.getElementById('submodule-label');
12261
12262 function populateSubmodules(root){{
12263 if(!subSel||!subLabel)return;
12264 while(subSel.options.length>1)subSel.remove(1);
12265 subSel.value='';
12266 var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
12267 fetch(url)
12268 .then(function(r){{return r.json();}})
12269 .then(function(subs){{
12270 if(!subs||!subs.length){{subLabel.style.display='none';return;}}
12271 subs.forEach(function(s){{
12272 var o=document.createElement('option');
12273 o.value=s.name;
12274 o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
12275 subSel.appendChild(o);
12276 }});
12277 subLabel.style.display='';
12278 }})
12279 .catch(function(){{subLabel.style.display='none';}});
12280 }}
12281
12282 var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div>';
12283
12284 function loadAndRender(){{
12285 var root = rootSel.value;
12286 var sub = subSel ? subSel.value : '';
12287 document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
12288 document.getElementById('data-table-wrap').innerHTML='';
12289 var url = '/api/metrics/history?limit=100'
12290 + (root ? '&root='+encodeURIComponent(root) : '')
12291 + (sub ? '&submodule='+encodeURIComponent(sub) : '');
12292 fetch(url).then(function(r){{return r.json();}}).then(function(data){{
12293 allData = data;
12294 render(data);
12295 updateStats(data);
12296 }}).catch(function(){{
12297 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>';
12298 }});
12299 }}
12300
12301 function render(data){{
12302 var yKey = document.getElementById('y-sel').value;
12303 var xMode = document.getElementById('x-sel').value;
12304
12305 // Filter for tag/release mode
12306 var pts = data;
12307 if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
12308
12309 // Sort oldest-first for the line chart
12310 pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12311
12312 var wrap = document.getElementById('chart-wrap');
12313 if(!pts.length){{
12314 var emptyMsg = (xMode === 'tag')
12315 ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
12316 : 'No scan data found for the selected filters.';
12317 wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
12318 renderTable([]);
12319 return;
12320 }}
12321
12322 var scaleEl=document.getElementById('scale-sel');
12323 var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
12324 renderTrendInto(wrap, pts, yKey, xMode, sc);
12325 renderTable(pts, yKey);
12326 }}
12327
12328 // Draw the trend area+line chart (with points and tooltips) into `wrap` at scale `sc`.
12329 // Shared by the inline chart and the Full View modal so both render identically.
12330 function renderTrendInto(wrap, pts, yKey, xMode, sc){{
12331 // Fill the container width (like the Chart.js charts) instead of a fixed 900px
12332 // canvas centered with empty margins; Chart Size (sc) drives height + detail.
12333 var availW=Math.round(wrap.clientWidth||wrap.offsetWidth||900*sc);
12334 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;
12335 var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
12336
12337 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
12338
12339 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">';
12340 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>';
12341
12342 var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
12343
12344 // Grid + Y axis ticks
12345 for(var ti=0;ti<=5;ti++){{
12346 var gy=PT+CH-Math.round(ti/5*CH);
12347 var gv=Math.round(ti/5*maxY);
12348 svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
12349 svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmtFull(gv)+'</text>';
12350 }}
12351
12352 // X axis labels (every N-th point to avoid crowding)
12353 var labelEvery=Math.max(1,Math.ceil(pts.length/10));
12354 pts.forEach(function(d,i){{
12355 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
12356 if(i%labelEvery===0||i===pts.length-1){{
12357 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)));
12358 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>';
12359 }}
12360 }});
12361
12362 // Axis label
12363 var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
12364 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>';
12365 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>';
12366
12367 // Area fill + line path
12368 var pathD='';
12369 pts.forEach(function(d,i){{
12370 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
12371 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
12372 pathD+=(i===0?'M':'L')+x+','+y;
12373 }});
12374 if(pts.length>1){{
12375 var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
12376 svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
12377 }}
12378 svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
12379
12380 // Data points (clickable) + permanent value labels
12381 var showLabels = pts.length <= 40;
12382 var labelEveryN = pts.length > 20 ? 2 : 1;
12383 pts.forEach(function(d,i){{
12384 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
12385 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
12386 var hasTags=d.tags&&d.tags.length>0;
12387 var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
12388 var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
12389 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+'"/>';
12390 if(showLabels && i%labelEveryN===0){{
12391 var lx=x, ly=y-r-5;
12392 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>';
12393 }}
12394 }});
12395
12396 svg+='</svg>';
12397 wrap.innerHTML=svg;
12398
12399 // Pixel Y of the line at chart-space x (straight segments → linear interpolation).
12400 function lineYAt(mx){{
12401 var n=pts.length;
12402 if(n===0)return PT+CH;
12403 if(n===1)return PT+CH-Math.round((Number(pts[0][yKey])||0)/maxY*CH);
12404 var fx=(mx-PL)/Math.max(CW,1)*(n-1);
12405 if(fx<0)fx=0; if(fx>n-1)fx=n-1;
12406 var i0=Math.floor(fx),i1=Math.min(i0+1,n-1),t=fx-i0;
12407 var y0=PT+CH-(Number(pts[i0][yKey])||0)/maxY*CH;
12408 var y1=PT+CH-(Number(pts[i1][yKey])||0)/maxY*CH;
12409 return y0+t*(y1-y0);
12410 }}
12411
12412 // SVG-level mousemove: show the value tooltip only when the pointer is over the
12413 // gradient fill (inside the chart and at/below the line) — never in the empty
12414 // space above the line. Cursor follows the same rule.
12415 (function(){{
12416 var svgEl=wrap.querySelector('svg');
12417 if(!svgEl)return;
12418 svgEl.addEventListener('mousemove',function(e){{
12419 if(e.target&&e.target.classList&&e.target.classList.contains('trend-pt'))return; // circle handles its own tooltip
12420 var rect=svgEl.getBoundingClientRect();
12421 var scaleX=W/Math.max(rect.width,1);
12422 var scaleY=H/Math.max(rect.height,1);
12423 var mouseX=(e.clientX-rect.left)*scaleX;
12424 var mouseY=(e.clientY-rect.top)*scaleY;
12425 var ly=lineYAt(mouseX);
12426 if(mouseX<PL||mouseX>PL+CW||mouseY<ly-6*sc||mouseY>PT+CH){{hideTT();svgEl.style.cursor='default';return;}}
12427 svgEl.style.cursor='pointer';
12428 var idx=Math.max(0,Math.min(pts.length-1,Math.round((mouseX-PL)/Math.max(CW,1)*(pts.length-1))));
12429 var d=pts[idx];
12430 var val=Number(d[yKey]);
12431 var lbl=xMode==='commit'&&d.commit?d.commit.substring(0,7):d.timestamp.substring(0,10);
12432 showTT(e,
12433 '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(lbl)+'</strong>'+
12434 (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(val)+'</strong>'+
12435 '<br><span style="font-size:11px;color:var(--muted);">'+d.timestamp.substring(0,10)+'</span>'
12436 );
12437 }});
12438 svgEl.addEventListener('mouseleave',function(){{hideTT();svgEl.style.cursor='default';}});
12439 }})();
12440
12441 // Attach point tooltips
12442 wrap.querySelectorAll('.trend-pt').forEach(function(c){{
12443 c.addEventListener('mouseover',function(e){{
12444 var d=pts[parseInt(this.dataset.idx)];
12445 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(''):'';
12446 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>':'';
12447 showTT(e,
12448 '<strong style="display:block;font-size:13px;margin-bottom:3px;">'+esc(d.project_label)+'</strong>'+
12449 (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
12450 'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
12451 (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
12452 );
12453 this.setAttribute('r','8');
12454 }});
12455 c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
12456 c.addEventListener('mousemove',moveTT);
12457 c.addEventListener('click',function(){{
12458 var d=pts[parseInt(this.dataset.idx)];
12459 if(d.html_url) window.open(d.html_url,'_blank');
12460 }});
12461 }});
12462 }}
12463
12464 var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
12465 var shProjFilter='', shBranchFilter='';
12466
12467 function fmtPST(isoStr){{
12468 if(!isoStr)return'';
12469 var d=new Date(isoStr);
12470 if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
12471 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);}}
12472 function p(n){{return n<10?'0'+n:String(n);}}
12473 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++;}}}}
12474 var yr=d.getUTCFullYear();
12475 var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
12476 var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
12477 var isDST=d>=dstStart&&d<dstEnd;
12478 var off=isDST?-7*3600*1000:-8*3600*1000;
12479 var lbl=isDST?'PDT':'PST';
12480 var loc=new Date(d.getTime()+off);
12481 return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
12482 }}
12483
12484 function getShRows(){{
12485 var proj=shProjFilter.toLowerCase().trim();
12486 var branch=shBranchFilter;
12487 return shData.filter(function(d){{
12488 if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
12489 if(branch&&(d.branch||'')!==branch)return false;
12490 return true;
12491 }});
12492 }}
12493
12494 function renderShPage(){{
12495 var filtered=getShRows();
12496 if(shSortCol){{
12497 filtered.sort(function(a,b){{
12498 var va,vb;
12499 if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
12500 if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
12501 else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
12502 else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
12503 else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
12504 return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
12505 }});
12506 }}
12507 var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
12508 shPage=Math.min(shPage,totalPages);
12509 var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
12510 var visible=filtered.slice(start,end);
12511 var tbody=document.getElementById('sh-tbody');
12512 if(!tbody)return;
12513 tbody.innerHTML=visible.map(function(d){{
12514 var tsHtml=esc(fmtPST(d.timestamp));
12515 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>';
12516 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>';
12517 var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span style="color:var(--muted)">—</span>';
12518 var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'—';
12519 var metricHtml='<span class="metric-num">'+fmtFull(d._metricVal)+'</span>';
12520 var reportCell='';
12521 if(d.html_url){{
12522 reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
12523 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>';}}
12524 reportCell+='</div>';
12525 }}else{{reportCell='<span style="color:var(--muted);font-size:11px;font-style:italic;">—</span>';}}
12526 if(d.submodule_links&&d.submodule_links.length){{
12527 reportCell+='<details class="submod-details"><summary>↳ '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
12528 d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
12529 reportCell+='</div></details>';
12530 }}
12531 return '<tr>'
12532 +'<td>'+tsHtml+'</td>'
12533 +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
12534 +'<td>'+runIdHtml+'</td>'
12535 +'<td>'+commitHtml+'</td>'
12536 +'<td>'+branchHtml+'</td>'
12537 +'<td>'+tags+'</td>'
12538 +'<td class="num">'+metricHtml+'</td>'
12539 +'<td class="report-cell">'+reportCell+'</td>'
12540 +'</tr>';
12541 }}).join('');
12542 var pgRange=document.getElementById('sh-pg-range');
12543 if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'\u2013'+end+' of '+total:'No results';
12544 var pgInfo=document.getElementById('sh-pg-info');
12545 if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
12546 var pgBtns=document.getElementById('sh-pg-btns');
12547 if(pgBtns){{
12548 pgBtns.innerHTML='';
12549 function mkPgBtn(lbl,pg,active,disabled){{
12550 var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
12551 if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
12552 return b;
12553 }}
12554 pgBtns.appendChild(mkPgBtn('\u2039',shPage-1,false,shPage===1));
12555 var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
12556 for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
12557 pgBtns.appendChild(mkPgBtn('\u203a',shPage+1,false,shPage===totalPages));
12558 }}
12559 }}
12560
12561 function wireTableBehavior(){{
12562 var pf=document.getElementById('sh-proj-filter');
12563 if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
12564 var bf=document.getElementById('sh-branch-filter');
12565 if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
12566 var rb=document.getElementById('sh-reset-btn');
12567 if(rb)rb.addEventListener('click',function(){{
12568 shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
12569 var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
12570 var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
12571 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');}});
12572 renderShPage();
12573 }});
12574 var pps=document.getElementById('sh-per-page');
12575 if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
12576 var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
12577 ths.forEach(function(th){{
12578 th.addEventListener('click',function(e){{
12579 if(e.target.classList.contains('col-resize-handle'))return;
12580 var col=th.dataset.col;
12581 if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
12582 ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
12583 th.classList.add('sort-'+shSortOrder);
12584 var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'\u2191':'\u2193';
12585 shPage=1;renderShPage();
12586 }});
12587 }});
12588 var table=document.getElementById('scan-history-table');
12589 if(!table)return;
12590 var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
12591 var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
12592 allThs.forEach(function(th,i){{
12593 var handle=th.querySelector('.col-resize-handle');
12594 if(!handle||!cols[i])return;
12595 var startX,startW;
12596 handle.addEventListener('mousedown',function(e){{
12597 e.stopPropagation();e.preventDefault();
12598 startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
12599 handle.classList.add('dragging');
12600 function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
12601 function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
12602 document.addEventListener('mousemove',onMove);
12603 document.addEventListener('mouseup',onUp);
12604 }});
12605 }});
12606 }}
12607
12608 function renderTable(pts, yKey){{
12609 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
12610 var wrap=document.getElementById('data-table-wrap');
12611 if(!pts||!pts.length){{wrap.innerHTML='';return;}}
12612 var yLabel=Y_LABELS[yKey]||yKey||'';
12613 shData=pts.slice().reverse();
12614 shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
12615 shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
12616 var branches={{}};
12617 shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
12618 var branchOpts='<option value="">All branches</option>';
12619 Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
12620 wrap.innerHTML=
12621 '<div class="chart-section-header">SCAN HISTORY</div>'+
12622 '<div class="filter-row">'+
12623 '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by path or name\u2026">'+
12624 '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
12625 '<button type="button" class="btn" id="sh-reset-btn">\u21bb Reset view</button>'+
12626 '</div>'+
12627 '<div class="table-wrap">'+
12628 '<table id="scan-history-table" class="data-table">'+
12629 '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
12630 '<thead><tr id="sh-thead">'+
12631 '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
12632 '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
12633 '<th>Run ID<div class="col-resize-handle"></div></th>'+
12634 '<th>Commit<div class="col-resize-handle"></div></th>'+
12635 '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
12636 '<th>Tags<div class="col-resize-handle"></div></th>'+
12637 '<th class="sortable num" data-col="metric" data-type="num">'+esc(yLabel)+'<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
12638 '<th>Report<div class="col-resize-handle"></div></th>'+
12639 '</tr></thead>'+
12640 '<tbody id="sh-tbody"></tbody>'+
12641 '</table>'+
12642 '</div>'+
12643 '<div class="pagination">'+
12644 '<span class="pagination-info" id="sh-pg-info"></span>'+
12645 '<div class="pagination-btns" id="sh-pg-btns"></div>'+
12646 '<div style="display:flex;align-items:center;gap:8px;">'+
12647 '<span style="font-size:13px;color:var(--muted);">Show</span>'+
12648 '<select class="filter-select" id="sh-per-page">'+
12649 '<option value="10">10 per page</option>'+
12650 '<option value="25" selected>25 per page</option>'+
12651 '<option value="50">50 per page</option>'+
12652 '<option value="100">100 per page</option>'+
12653 '</select>'+
12654 '<span style="font-size:13px;color:var(--muted);" id="sh-pg-range"></span>'+
12655 '</div>'+
12656 '</div>';
12657 wireTableBehavior();
12658 renderShPage();
12659 }}
12660
12661 function exportXLSX(){{
12662 if(!allData||!allData.length){{alert('No data to export yet.');return;}}
12663 var xbtn=document.getElementById('export-xlsx-btn');
12664 var xorig=xbtn?xbtn.innerHTML:'';
12665 if(xbtn){{xbtn.disabled=true;xbtn.textContent='Preparing\u2026';}}
12666 var root=rootSel.value;
12667 var url='/api/metrics/churn?limit=500'+(root?'&root='+encodeURIComponent(root):'');
12668 fetch(url).then(function(r){{return r.ok?r.json():[];}}).catch(function(){{return [];}}).then(function(churn){{
12669 var cm={{}};(churn||[]).forEach(function(c){{cm[c.run_id]=c;}});
12670 buildAndDownloadXLSX(cm);
12671 }}).finally(function(){{if(xbtn){{xbtn.disabled=false;xbtn.innerHTML=xorig;}}}});
12672 }}
12673
12674 function buildAndDownloadXLSX(churnMap){{
12675 var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
12676 // X-axis is the git commit. Dedupe by project+commit, keeping the latest scan
12677 // (sorted is newest-first), so a given project/commit appears at most once.
12678 var seenPC={{}},dedup=[];
12679 sorted.forEach(function(d){{var k=(d.project_label||'')+'|'+(d.commit||'');if(!seenPC[k]){{seenPC[k]=1;dedup.push(d);}}}});
12680 var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL','Added','Deleted','Modified','Unmodified','Total'];
12681 var s1R=dedup.map(function(d){{
12682 var c=churnMap[d.run_id]||{{}};
12683 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,(+(c.added)||0)+(+(c.removed)||0)+(+(c.modified)||0)+(+(c.unmodified)||0)];
12684 }});
12685 var pm={{}};
12686 dedup.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
12687 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'];
12688 var s2R=Object.keys(pm).map(function(p){{
12689 var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
12690 var lat=sc[sc.length-1],fst=sc[0];
12691 var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
12692 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);
12693 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];
12694 }});
12695 var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}},{{name:'Focus Chart',headers:[],rows:[]}}],s1R,s2R);
12696 var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
12697 a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
12698 a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
12699 }}
12700
12701 function buildXLSX(sheets,chartRows,chartRows2){{
12702 function s2b(s){{return new TextEncoder().encode(s);}}
12703 function xe(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}}
12704 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;}}
12705 function crc32(d){{
12706 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;}}}}
12707 var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
12708 }}
12709 function buildSheet(hdr,rows,drawRid,withCtrl){{
12710 var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
12711 if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
12712 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
12713 x+='<row r="1">';
12714 hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
12715 if(withCtrl){{x+='<c r="Q1" t="inlineStr" s="1"><is><t>Selected Metric (set on Focus Chart tab)</t></is></c>';}}
12716 x+='</row>';
12717 rows.forEach(function(row,ri){{
12718 var rn=ri+2;
12719 x+='<row r="'+rn+'">';
12720 row.forEach(function(cell,ci){{
12721 var addr=col2l(ci+1)+rn;
12722 if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
12723 else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
12724 }});
12725 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\",\"Total\"}},0),F"+rn+",G"+rn+",H"+rn+",I"+rn+",L"+rn+",M"+rn+",N"+rn+",O"+rn+",P"+rn+")</f><v>"+Number(row[5])+"</v></c>";}}
12726 x+='</row>';
12727 }});
12728 x+='</sheetData>';
12729 if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
12730 return x+'</worksheet>';
12731 }}
12732 function buildChartXML(rows){{
12733 var sn="'Scan History'";
12734 var nr=rows.length,er=nr+1;
12735 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'}}];
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>';
12740 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>';
12741 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12742 sd.forEach(function(s,i){{
12743 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
12744 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>';
12745 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
12746 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>';
12747 x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12748 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12749 x+='</c:strCache></c:strRef></c:cat>';
12750 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+'"/>';
12751 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
12752 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12753 }});
12754 x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
12755 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>';
12756 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>';
12757 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
12758 return x;
12759 }}
12760 function buildChartXML2(rows){{
12761 var sn="'By Project'";
12762 var nr=rows.length,er=nr+1;
12763 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'}}];
12764 var catCol='A',catIdx=0;
12765 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12766 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">';
12767 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart>';
12768 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>';
12769 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12770 sd.forEach(function(s,i){{
12771 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
12772 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>';
12773 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
12774 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>';
12775 x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12776 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12777 x+='</c:strCache></c:strRef></c:cat>';
12778 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+'"/>';
12779 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
12780 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12781 }});
12782 x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
12783 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>';
12784 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>';
12785 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
12786 return x;
12787 }}
12788 function buildChartXML3(rows){{
12789 var sn="'Scan History'";
12790 var nr=rows.length,er=nr+1;
12791 var catCol='C',catIdx=2;
12792 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12793 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">';
12794 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
12795 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
12796 x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
12797 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>";
12798 x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
12799 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>';
12800 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>';
12801 x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
12802 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
12803 x+='</c:strCache></c:strRef></c:cat>';
12804 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+'"/>';
12805 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
12806 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
12807 x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
12808 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>';
12809 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>';
12810 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>';
12811 return x;
12812 }}
12813 function buildFocusSheet(drawRid){{
12814 var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
12815 if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
12816 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>';
12817 x+='<cols><col min="1" max="1" width="11" customWidth="1"/><col min="2" max="2" width="20" customWidth="1"/></cols>';
12818 x+='<sheetData><row r="1">';
12819 x+='<c r="A1" t="inlineStr" s="1"><is><t>Metric:</t></is></c>';
12820 x+='<c r="B1" t="inlineStr"><is><t>Code Lines</t></is></c>';
12821 x+='<c r="D1" t="inlineStr"><is><t>← Pick a metric from the dropdown to update the chart below</t></is></c>';
12822 x+='</row></sheetData>';
12823 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,Total"</formula1></dataValidation></dataValidations>';
12824 if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
12825 return x+'</worksheet>';
12826 }}
12827 var hasChart=!!(chartRows&&chartRows.length);
12828 var nr=hasChart?chartRows.length:0;
12829 var hasChart2=!!(chartRows2&&chartRows2.length);
12830 var nr2=hasChart2?chartRows2.length:0;
12831 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>';
12832 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"/>';
12833 sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
12834 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"/>';}}
12835 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"/>';}}
12836 ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
12837 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>';
12838 var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
12839 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"/>';}});
12840 wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
12841 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>';
12842 sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
12843 wbx+='</sheets></workbook>';
12844 var files=[
12845 {{name:'[Content_Types].xml',data:s2b(ct)}},
12846 {{name:'_rels/.rels',data:s2b(dotrels)}},
12847 {{name:'xl/workbook.xml',data:s2b(wbx)}},
12848 {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
12849 {{name:'xl/styles.xml',data:s2b(styl)}}
12850 ];
12851 // Chart embedded directly in Scan History (sheet1); By Project is plain
12852 sheets.forEach(function(s,i){{
12853 var sx;
12854 if(s.name==='Focus Chart'){{sx=buildFocusSheet(hasChart?'rId1':null);}}
12855 else{{sx=buildSheet(s.headers,s.rows,(hasChart&&i===0)?'rId1':(hasChart2&&i===1)?'rId1':null,(hasChart&&i===0));}}
12856 files.push({{name:'xl/worksheets/sheet'+(i+1)+'.xml',data:s2b(sx)}});
12857 }});
12858 if(hasChart){{
12859 var fromRow=nr+4,toRow=nr+34;
12860 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>')}});
12861 var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12862 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">';
12863 drx+='<xdr:twoCellAnchor editAs="twoCell">';
12864 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>';
12865 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>';
12866 drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12867 drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12868 drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12869 drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12870 drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
12871 files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
12872 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>')}});
12873 files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
12874 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>')}});
12875 var drx3='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12876 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">';
12877 drx3+='<xdr:twoCellAnchor editAs="twoCell">';
12878 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>';
12879 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>';
12880 drx3+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12881 drx3+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12882 drx3+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12883 drx3+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12884 drx3+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
12885 files.push({{name:'xl/drawings/drawing3.xml',data:s2b(drx3)}});
12886 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>')}});
12887 files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
12888 }}
12889 if(hasChart2){{
12890 var fromRow2=nr2+4,toRow2=nr2+36;
12891 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>')}});
12892 var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
12893 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">';
12894 drx2+='<xdr:twoCellAnchor editAs="twoCell">';
12895 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>';
12896 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>';
12897 drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
12898 drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
12899 drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
12900 drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
12901 drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
12902 files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
12903 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>')}});
12904 files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
12905 }}
12906 var parts=[],offsets=[],total=0;
12907 files.forEach(function(f){{
12908 offsets.push(total);
12909 var nb=s2b(f.name),crc=crc32(f.data);
12910 var h=new DataView(new ArrayBuffer(30+nb.length));
12911 h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
12912 h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
12913 h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
12914 h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
12915 for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
12916 parts.push(new Uint8Array(h.buffer));parts.push(f.data);
12917 total+=30+nb.length+f.data.length;
12918 }});
12919 var cdStart=total;
12920 files.forEach(function(f,fi){{
12921 var nb=s2b(f.name),crc=crc32(f.data);
12922 var cd=new DataView(new ArrayBuffer(46+nb.length));
12923 cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
12924 cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
12925 cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
12926 cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
12927 cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
12928 for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
12929 parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
12930 }});
12931 var cdSz=total-cdStart;
12932 var eocd=new DataView(new ArrayBuffer(22));
12933 eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
12934 eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
12935 eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
12936 parts.push(new Uint8Array(eocd.buffer));
12937 var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
12938 var out=new Uint8Array(sz);var off=0;
12939 parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
12940 return out.buffer;
12941 }}
12942
12943 function trendTitleParts(){{
12944 var ySel=document.getElementById('y-sel'),xSel=document.getElementById('x-sel');
12945 var subSelEl=document.getElementById('sub-sel');
12946 var metricLbl=ySel?ySel.options[ySel.selectedIndex].text:'Metric';
12947 var xLbl=xSel?xSel.options[xSel.selectedIndex].text:'';
12948 var proj=(document.getElementById('root-sel').value)||'All projects';
12949 var subTxt=(subSelEl&&subSelEl.value)?(' / '+subSelEl.value):'';
12950 var cnt=(allData&&allData.length)||0;
12951 var now=new Date();
12952 function p2(n){{return(n<10?'0':'')+n;}}
12953 var dstr=now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate())+' '+p2(now.getHours())+':'+p2(now.getMinutes());
12954 return{{title:metricLbl+' \u2014 '+xLbl,sub:'Project: '+proj+subTxt+' \u00b7 '+cnt+' scan'+(cnt===1?'':'s')+' \u00b7 Generated '+dstr,date:dstr}};
12955 }}
12956
12957 function exportPNG(){{
12958 var svgEl=document.querySelector('#chart-wrap svg');
12959 if(!svgEl){{alert('No chart to export yet.');return;}}
12960 var svgStr=new XMLSerializer().serializeToString(svgEl);
12961 var vb=svgEl.viewBox.baseVal,scale=2;
12962 var headerH=84,footerH=36;
12963 var lw=(vb.width||900),lh=(vb.height||380);
12964 var w=lw*scale,h=(lh+headerH+footerH)*scale;
12965 var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
12966 var url=URL.createObjectURL(blob);
12967 var img=new Image();
12968 var tp=trendTitleParts();
12969 img.onload=function(){{
12970 var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
12971 var ctx=canvas.getContext('2d');
12972 var cs=getComputedStyle(document.body);
12973 var bg=cs.getPropertyValue('--bg').trim()||'#f5efe8';
12974 var oxide=cs.getPropertyValue('--oxide').trim()||'#C45C10';
12975 var muted=cs.getPropertyValue('--muted').trim()||'#7b675b';
12976 ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
12977 ctx.scale(scale,scale);
12978 ctx.textBaseline='alphabetic';ctx.textAlign='left';
12979 ctx.fillStyle=oxide;ctx.font='800 23px '+FONT;ctx.fillText(tp.title,24,40);
12980 ctx.fillStyle=muted;ctx.font='600 13px '+FONT;ctx.fillText(tp.sub,24,62);
12981 ctx.fillStyle=muted;ctx.font='700 12px '+FONT;ctx.textAlign='right';ctx.fillText('OxideSLOC Trend Report',lw-24,40);ctx.textAlign='left';
12982 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;
12983 ctx.drawImage(img,0,headerH);
12984 var fy=headerH+lh;
12985 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;
12986 ctx.fillStyle=muted;ctx.font='600 11px '+FONT;ctx.textAlign='center';
12987 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);
12988 ctx.textAlign='left';
12989 URL.revokeObjectURL(url);
12990 var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
12991 }};
12992 img.src=url;
12993 }}
12994
12995 function exportPDF(){{
12996 var svgEl=document.querySelector('#chart-wrap svg');
12997 if(!svgEl){{alert('No chart to export yet.');return;}}
12998 var tp=trendTitleParts();
12999 var svgStr=new XMLSerializer().serializeToString(svgEl);
13000 var statsEl=document.getElementById('trend-stats');
13001 var statsHtml=statsEl?statsEl.innerHTML:'';
13002 var yK=document.getElementById('y-sel').value;
13003 var yLabels={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
13004 var yL=yLabels[yK]||yK;
13005 var rowsDesc=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
13006 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>';
13007 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>';}});
13008 tableHtml+='</tbody></table>';
13009 var css='<style>'
13010 +'*{{box-sizing:border-box;}}'
13011 +'html,body{{margin:0;padding:0;}}'
13012 // Masthead/footer flow in document order — a position:fixed header repeats
13013 // on every printed page in Chromium and hides the rows beneath it on pages
13014 // 2+. The trend table's <thead> repeats per page natively instead.
13015 +'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;}}'
13016 +'.rep-masthead{{background:#191c26;color:#fff;display:flex;justify-content:space-between;align-items:center;padding:15px 34px;}}'
13017 +'.rep-mast-left{{display:flex;align-items:baseline;gap:14px;}}'
13018 +'.rep-mast-brand{{font-size:19px;font-weight:900;letter-spacing:-.01em;}}'
13019 +'.rep-mast-sub{{font-size:12.5px;color:rgba(255,255,255,0.65);font-weight:600;}}'
13020 +'.rep-mast-ts{{font-size:11px;color:rgba(255,255,255,0.65);font-weight:600;}}'
13021 +'.rep-body{{padding:22px 34px 0;}}'
13022 +'.rep-head{{display:flex;justify-content:space-between;align-items:flex-start;border-bottom:3px solid #C45C10;padding-bottom:14px;margin-bottom:18px;}}'
13023 +'.rep-title{{font-size:23px;font-weight:900;margin:0;color:#241813;}}'
13024 +'.rep-sub{{font-size:13px;color:#7b675b;margin:6px 0 0;}}'
13025 +'.rep-brand{{font-size:14px;font-weight:800;color:#C45C10;text-align:right;white-space:nowrap;}}'
13026 +'.rep-brand small{{display:block;font-weight:600;color:#7b675b;font-size:11px;margin-top:2px;}}'
13027 +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 22px;}}'
13028 +'.stat-chip{{border:1px solid #e6d0bf;border-radius:11px;padding:9px 12px;position:relative;background:#fcf8f3;overflow:hidden;}}'
13029 +'.stat-chip-tip{{display:none!important;}}'
13030 +'.stat-chip-val{{font-size:16px;font-weight:900;color:#C45C10;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}'
13031 +'.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;}}'
13032 +'.stat-chip-exact{{position:absolute;bottom:5px;right:9px;font-size:9px;color:#7b675b;}}'
13033 +'.stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}'
13034 +'.rep-chart{{text-align:center;margin:0 0 22px;}}'
13035 +'.rep-chart svg{{max-width:100%;height:auto;}}'
13036 +'.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;}}'
13037 +'.filter-row{{display:none!important;}}'
13038 +'table{{border-collapse:collapse;width:100%;font-size:11px;}}'
13039 +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;}}'
13040 +'th{{background:#f0e9e0;font-weight:800;}}'
13041 +'.sort-icon,.col-resize-handle{{display:none!important;}}'
13042 +'.pagination,.table-pager,.sh-pager{{display:none!important;}}'
13043 +'.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;}}'
13044 +'.rep-foot-gen{{margin-top:2px;color:rgba(255,255,255,0.55);}}'
13045 +'</style>';
13046 var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Trend Report</title>'+css+'</head><body>'
13047 +'<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>'
13048 +'<div class="rep-body">'
13049 +'<div class="rep-head"><div><h1 class="rep-title">'+tp.title+'</h1><p class="rep-sub">'+tp.sub+'</p></div>'
13050 +'<div class="rep-brand">OxideSLOC<small>Trend Report</small></div></div>'
13051 +'<div class="summary-strip">'+statsHtml+'</div>'
13052 +'<div class="rep-chart">'+svgStr+'</div>'
13053 +tableHtml
13054 +'</div>'
13055 +'<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>'
13056 +'</body></html>';
13057 window.slocExportPdf({{html:doc,filename:'oxide-sloc-trend-report.pdf',button:document.getElementById('export-pdf-btn')}});
13058 }}
13059
13060 ['y-sel','x-sel','scale-sel'].forEach(function(id){{
13061 var el=document.getElementById(id);
13062 if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
13063 }});
13064 // Reflow the width-filling SVG chart when the window resizes (debounced), so it
13065 // tracks the container like the responsive Chart.js charts do.
13066 var _rsT=null;
13067 window.addEventListener('resize',function(){{
13068 if(_rsT)clearTimeout(_rsT);
13069 _rsT=setTimeout(function(){{ if(allData&&allData.length)render(allData); }},150);
13070 }});
13071 rootSel.addEventListener('change',function(){{
13072 populateSubmodules(rootSel.value);
13073 loadAndRender();
13074 }});
13075 if(subSel)subSel.addEventListener('change',loadAndRender);
13076
13077 // ── Full View modal: re-render the trend chart larger using the same drawing code ──
13078 (function(){{
13079 var fvBtn=document.getElementById('tr-chart-fv-btn');
13080 if(!fvBtn)return;
13081 function closeFv(ov){{ if(ov&&ov.parentNode)ov.parentNode.removeChild(ov); hideTT(); }}
13082 fvBtn.addEventListener('click',function(){{
13083 if(!allData||!allData.length){{alert('No chart to expand yet.');return;}}
13084 var yKey=document.getElementById('y-sel').value;
13085 var xMode=document.getElementById('x-sel').value;
13086 var pts=allData;
13087 if(xMode==='tag')pts=allData.filter(function(d){{return d.tags&&d.tags.length>0;}});
13088 pts=pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
13089 if(!pts.length){{alert('No scan data found for the selected filters.');return;}}
13090 var tp=trendTitleParts();
13091 var ov=document.createElement('div');
13092 ov.className='tr-chart-full-modal';
13093 ov.innerHTML='<div class="tr-chart-full-inner">'
13094 +'<button type="button" class="settings-close" style="position:absolute;top:16px;right:18px;" aria-label="Close">'
13095 +'<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>'
13096 +'<div style="font-size:18px;font-weight:900;color:var(--oxide);margin:0 40px 2px 0;">'+esc(tp.title)+'</div>'
13097 +'<div style="font-size:12.5px;color:var(--muted);margin-bottom:16px;">'+esc(tp.sub)+'</div>'
13098 +'<div id="tr-fv-chart-wrap" class="chart-wrap"></div></div>';
13099 document.body.appendChild(ov);
13100 var fvWrap=ov.querySelector('#tr-fv-chart-wrap');
13101 renderTrendInto(fvWrap, pts, yKey, xMode, 1.7);
13102 ov.addEventListener('click',function(e){{ if(e.target===ov)closeFv(ov); }});
13103 ov.querySelector('.settings-close').addEventListener('click',function(){{closeFv(ov);}});
13104 document.addEventListener('keydown',function esc2(e){{ if(e.key==='Escape'){{closeFv(ov);document.removeEventListener('keydown',esc2);}} }});
13105 }});
13106 }})();
13107
13108 var xlsxBtn=document.getElementById('export-xlsx-btn');
13109 if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
13110 var pngBtn=document.getElementById('export-png-btn');
13111 if(pngBtn)pngBtn.addEventListener('click',exportPNG);
13112 var pdfBtn=document.getElementById('export-pdf-btn');
13113 if(pdfBtn)pdfBtn.addEventListener('click',exportPDF);
13114
13115 // ── Clean-up modal ───────────────────────────────────────────────────────
13116 (function(){{
13117 var triggerBtn=document.getElementById('cleanup-runs-btn');
13118 if(!triggerBtn)return;
13119 var modal=document.createElement('div');
13120 modal.className='tr-modal-backdrop';
13121 modal.innerHTML='<div class="tr-modal" style="max-width:520px;">'
13122 +'<div class="tr-modal-head">'
13123 +'<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>'
13124 +'<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>'
13125 +'</div>'
13126 +'<div class="tr-modal-body">'
13127 +'<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>'
13128 +'<label style="font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;">Delete runs older than</label>'
13129 +'<div style="display:flex;align-items:center;gap:8px;margin:8px 0 4px;">'
13130 +'<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;">'
13131 +'<span style="font-size:13px;color:var(--muted);">days</span></div>'
13132 +'<div id="cleanup-status" style="display:none;padding:10px 14px;border-radius:9px;font-size:13px;font-weight:600;margin-top:16px;"></div>'
13133 +'</div>'
13134 +'<div class="tr-modal-foot">'
13135 +'<button class="tr-btn tr-btn-secondary" id="cleanup-cancel-btn" type="button">Cancel</button>'
13136 +'<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>'
13137 +'</div></div>';
13138 document.body.appendChild(modal);
13139 triggerBtn.addEventListener('click',function(){{
13140 document.getElementById('cleanup-status').style.display='none';
13141 modal.style.display='flex';
13142 }});
13143 document.getElementById('cleanup-cancel-btn').addEventListener('click',function(){{modal.style.display='none';}});
13144 modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
13145 document.getElementById('cleanup-confirm-btn').addEventListener('click',function(){{
13146 var days=parseInt(document.getElementById('cleanup-days-input').value,10)||30;
13147 var confirmBtn=this;
13148 confirmBtn.disabled=true;
13149 var status=document.getElementById('cleanup-status');
13150 status.style.display='block';
13151 status.style.background='#dbeafe';status.style.color='#1e40af';
13152 status.textContent='Deleting\u2026';
13153 fetch('/api/runs/cleanup',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{older_than_days:days}})}})
13154 .then(function(resp){{
13155 return resp.json().then(function(d){{
13156 if(resp.ok){{
13157 status.style.background='#dcfce7';status.style.color='#166534';
13158 status.textContent='Deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+' older than '+days+' days. Refreshing\u2026';
13159 setTimeout(function(){{window.location.reload();}},1500);
13160 }}else{{
13161 status.style.background='#fee2e2';status.style.color='#991b1b';
13162 status.textContent='Error: '+(d.error||'Unexpected error');
13163 confirmBtn.disabled=false;
13164 }}
13165 }});
13166 }})
13167 .catch(function(e){{
13168 status.style.background='#fee2e2';status.style.color='#991b1b';
13169 status.textContent='Network error: '+String(e);
13170 confirmBtn.disabled=false;
13171 }});
13172 }});
13173 }})();
13174
13175 // ── Retention policy panel ────────────────────────────────────────────────
13176 (function(){{
13177 var triggerBtn=document.getElementById('retention-policy-btn');
13178 if(!triggerBtn)return;
13179 var modal=document.createElement('div');
13180 modal.className='tr-modal-backdrop';
13181 modal.style.zIndex='9001';
13182 modal.innerHTML=''
13183 +'<div class="tr-modal" style="max-width:640px;">'
13184 +'<div class="tr-modal-head">'
13185 +'<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>'
13186 +'<div><h2 class="tr-modal-title">Retention Policy</h2><p class="tr-modal-sub">Scheduled automatic cleanup of old scan runs</p></div>'
13187 +'</div>'
13188 +'<div class="tr-modal-body">'
13189 +'<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>'
13190 +'<div style="display:flex;align-items:center;gap:10px;margin-bottom:22px;">'
13191 +'<input type="checkbox" id="rp-enabled" style="width:16px;height:16px;cursor:pointer;accent-color:var(--oxide);">'
13192 +'<label for="rp-enabled" style="font-size:14px;font-weight:700;cursor:pointer;">Enable auto-cleanup</label>'
13193 +'</div>'
13194 +'<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:20px;">'
13195 +'<div>'
13196 +'<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>'
13197 +'<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;">'
13198 +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Delete runs older than N days</div>'
13199 +'</div>'
13200 +'<div>'
13201 +'<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>'
13202 +'<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;">'
13203 +'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Keep only the N most recent runs</div>'
13204 +'</div>'
13205 +'</div>'
13206 +'<div style="margin-bottom:20px;">'
13207 +'<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>'
13208 +'<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;">'
13209 +'<option value="1">Every hour</option>'
13210 +'<option value="6">Every 6 hours</option>'
13211 +'<option value="12">Every 12 hours</option>'
13212 +'<option value="24" selected>Every 24 hours</option>'
13213 +'<option value="48">Every 2 days</option>'
13214 +'<option value="72">Every 3 days</option>'
13215 +'<option value="168">Every week</option>'
13216 +'</select>'
13217 +'</div>'
13218 +'<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>'
13219 +'<div id="rp-status" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:18px;"></div>'
13220 +'</div>'
13221 +'<div class="tr-modal-foot">'
13222 +'<button class="tr-btn tr-btn-secondary" id="rp-close-btn" type="button">Close</button>'
13223 +'<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>'
13224 +'<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>'
13225 +'</div>'
13226 +'</div>';
13227 document.body.appendChild(modal);
13228
13229 function rpShowStatus(msg,ok){{
13230 var s=document.getElementById('rp-status');
13231 s.style.display='block';
13232 s.style.background=ok?'#dcfce7':'#fee2e2';
13233 s.style.color=ok?'#166534':'#991b1b';
13234 s.textContent=msg;
13235 }}
13236 function fmtAgo(iso){{
13237 if(!iso)return'Never';
13238 var diff=Math.floor((Date.now()-new Date(iso).getTime())/1000);
13239 if(diff<60)return diff+'s ago';
13240 if(diff<3600)return Math.floor(diff/60)+'m ago';
13241 if(diff<86400)return Math.floor(diff/3600)+'h ago';
13242 return Math.floor(diff/86400)+'d ago';
13243 }}
13244 function loadPolicy(){{
13245 fetch('/api/cleanup-policy')
13246 .then(function(r){{return r.json();}})
13247 .then(function(d){{
13248 var p=d.policy;
13249 document.getElementById('rp-enabled').checked=p?p.enabled:false;
13250 document.getElementById('rp-max-age').value=(p&&p.max_age_days!=null)?p.max_age_days:'';
13251 document.getElementById('rp-max-count').value=(p&&p.max_run_count!=null)?p.max_run_count:'';
13252 var sel=document.getElementById('rp-interval');
13253 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;}}}}}}
13254 var lr=document.getElementById('rp-last-run');
13255 if(d.last_run_at){{
13256 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'):'');
13257 }}else{{
13258 lr.textContent='Auto-cleanup has not run yet.';
13259 }}
13260 }})
13261 .catch(function(){{document.getElementById('rp-last-run').textContent='Could not load policy.';}});
13262 }}
13263
13264 triggerBtn.addEventListener('click',function(){{
13265 document.getElementById('rp-status').style.display='none';
13266 loadPolicy();
13267 modal.style.display='flex';
13268 }});
13269 document.getElementById('rp-close-btn').addEventListener('click',function(){{modal.style.display='none';}});
13270 modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
13271
13272 document.getElementById('rp-save-btn').addEventListener('click',function(){{
13273 var enabled=document.getElementById('rp-enabled').checked;
13274 var ageVal=document.getElementById('rp-max-age').value.trim();
13275 var countVal=document.getElementById('rp-max-count').value.trim();
13276 var intervalHours=parseInt(document.getElementById('rp-interval').value,10)||24;
13277 if(enabled&&!ageVal&&!countVal){{
13278 rpShowStatus('Set at least one rule (max age or max count) before enabling.',false);
13279 return;
13280 }}
13281 var body={{enabled:enabled,max_age_days:ageVal?parseInt(ageVal,10):null,max_run_count:countVal?parseInt(countVal,10):null,interval_hours:intervalHours}};
13282 var saveBtn=document.getElementById('rp-save-btn');
13283 saveBtn.disabled=true;
13284 fetch('/api/cleanup-policy',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify(body)}})
13285 .then(function(r){{
13286 if(r.status===204||r.ok){{rpShowStatus('Policy saved'+(enabled?'. Background task started.':'.'),true);}}
13287 else{{return r.json().then(function(d){{rpShowStatus('Error: '+(d.error||'Unexpected error'),false);}});}}
13288 }})
13289 .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
13290 .finally(function(){{saveBtn.disabled=false;}});
13291 }});
13292
13293 document.getElementById('rp-run-now-btn').addEventListener('click',function(){{
13294 var btn=this;
13295 var orig=btn.innerHTML;
13296 btn.disabled=true;
13297 btn.textContent='Running\u2026';
13298 fetch('/api/cleanup-policy/run-now',{{method:'POST'}})
13299 .then(function(r){{return r.json();}})
13300 .then(function(d){{
13301 rpShowStatus('Cleanup complete: deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+'.',true);
13302 loadPolicy();
13303 }})
13304 .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
13305 .finally(function(){{btn.disabled=false;btn.innerHTML=orig;}});
13306 }});
13307 }})();
13308
13309 populateSubmodules(rootSel.value);
13310 loadAndRender();
13311
13312 (function randomizeWatermarks() {{
13313 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
13314 if (!wms.length) return;
13315 var placed = [];
13316 function tooClose(top, left) {{
13317 for (var i = 0; i < placed.length; i++) {{
13318 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
13319 if (dt < 16 && dl < 12) return true;
13320 }}
13321 return false;
13322 }}
13323 function pick(leftBand) {{
13324 for (var attempt = 0; attempt < 50; attempt++) {{
13325 var top = Math.random() * 88 + 2;
13326 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
13327 if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
13328 }}
13329 var top = Math.random() * 88 + 2;
13330 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
13331 placed.push([top, left]); return [top, left];
13332 }}
13333 var half = Math.floor(wms.length / 2);
13334 wms.forEach(function (img, i) {{
13335 var pos = pick(i < half);
13336 var size = Math.floor(Math.random() * 100 + 120);
13337 var rot = (Math.random() * 360).toFixed(1);
13338 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
13339 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;
13340 }});
13341 }})();
13342 (function spawnCodeParticles() {{
13343 var container = document.getElementById('code-particles');
13344 if (!container) return;
13345 var snippets = [
13346 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
13347 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
13348 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
13349 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
13350 'fn main() {{','.rs .go .py','sloc_core','render_html','2,163 code'
13351 ];
13352 var count = 38;
13353 for (var i = 0; i < count; i++) {{
13354 (function(idx) {{
13355 var el = document.createElement('span');
13356 el.className = 'code-particle';
13357 el.textContent = snippets[idx % snippets.length];
13358 var left = Math.random() * 94 + 2;
13359 var top = Math.random() * 88 + 6;
13360 var dur = (Math.random() * 10 + 9).toFixed(1);
13361 var delay = (Math.random() * 18).toFixed(1);
13362 var rot = (Math.random() * 26 - 13).toFixed(1);
13363 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
13364 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
13365 container.appendChild(el);
13366 }})(i);
13367 }}
13368 }})();
13369 </script>
13370 <footer class="site-footer">
13371 local code analysis - metrics, history and reports
13372 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Local</em>
13373 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
13374 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
13375 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
13376 · <a href="/api-docs" rel="noopener">REST API</a>
13377 </footer>
13378 <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>
13379 {toast_assets}
13380</body>
13381</html>"##,
13382 );
13383
13384 Html(html).into_response()
13385}
13386
13387fn compute_cov_pct_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
13388 use std::collections::HashMap;
13389 if !per_file_records.iter().any(|f| f.coverage.is_some()) {
13390 return vec![];
13391 }
13392 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
13393 for rec in per_file_records {
13394 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
13395 let e = totals.entry(lang.display_name().to_string()).or_default();
13396 e.0 += u64::from(cov.lines_found);
13397 e.1 += u64::from(cov.lines_hit);
13398 }
13399 }
13400 #[allow(clippy::cast_precision_loss)] let mut pairs: Vec<(String, f64)> = totals
13402 .into_iter()
13403 .filter(|(_, (found, _))| *found > 0)
13404 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
13405 .collect();
13406 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
13407 pairs
13408 .iter()
13409 .map(|(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}))
13410 .collect()
13411}
13412
13413fn compute_cov_tiers(per_file_records: &[sloc_core::FileRecord]) -> (u64, u64, u64) {
13414 let mut high = 0u64;
13415 let mut mid = 0u64;
13416 let mut low = 0u64;
13417 for rec in per_file_records {
13418 if let Some(cov) = &rec.coverage {
13419 if cov.lines_found == 0 {
13420 continue;
13421 }
13422 let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
13423 if pct >= 80.0 {
13424 high += 1;
13425 } else if pct >= 50.0 {
13426 mid += 1;
13427 } else {
13428 low += 1;
13429 }
13430 }
13431 }
13432 (high, mid, low)
13433}
13434
13435fn compute_file_cov_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
13436 let mut arr: Vec<serde_json::Value> = per_file_records
13437 .iter()
13438 .filter_map(|rec| {
13439 rec.coverage.as_ref().map(|cov| {
13440 let line_pct = if cov.lines_found > 0 {
13441 (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
13442 / 10.0
13443 } else {
13444 0.0
13445 };
13446 let fn_pct = if cov.functions_found > 0 {
13447 (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
13448 .round()
13449 / 10.0
13450 } else {
13451 -1.0
13452 };
13453 serde_json::json!({
13454 "rel": rec.relative_path,
13455 "lang": rec.language.map_or("?", |l| l.display_name()),
13456 "line_pct": line_pct,
13457 "fn_pct": fn_pct,
13458 "lhit": cov.lines_hit,
13459 "lfound": cov.lines_found,
13460 "fhit": cov.functions_hit,
13461 "ffound": cov.functions_found,
13462 })
13463 })
13464 })
13465 .collect();
13466 arr.sort_by(|a, b| {
13467 let pa = a["line_pct"].as_f64().unwrap_or(0.0);
13468 let pb = b["line_pct"].as_f64().unwrap_or(0.0);
13469 pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
13470 });
13471 arr
13472}
13473
13474#[allow(clippy::cast_precision_loss)] fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
13476 let mut langs: Vec<&sloc_core::LanguageSummary> = run
13477 .totals_by_language
13478 .iter()
13479 .filter(|l| l.test_count > 0)
13480 .collect();
13481 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13482 let lang_tests: Vec<serde_json::Value> = langs
13483 .iter()
13484 .map(|l| {
13485 let d = if l.code_lines > 0 {
13486 l.test_count as f64 / l.code_lines as f64 * 1000.0
13487 } else {
13488 0.0
13489 };
13490 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
13491 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
13492 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
13493 })
13494 .collect();
13495 let cov_arr = compute_cov_pct_arr(&run.per_file_records);
13496 let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
13497 let t = &run.summary_totals;
13498 let total_tests = t.test_count;
13499 let density = if t.code_lines > 0 {
13500 total_tests as f64 / t.code_lines as f64 * 1000.0
13501 } else {
13502 0.0
13503 };
13504 let most_tested = langs.first().map_or_else(
13505 || "\u{2014}".to_string(),
13506 |l| l.language.display_name().to_string(),
13507 );
13508 let test_files: u64 = run
13509 .per_file_records
13510 .iter()
13511 .filter(|f| f.raw_line_categories.test_count > 0)
13512 .count() as u64;
13513 let cov_line = if t.coverage_lines_found > 0 {
13514 format!(
13515 "{:.1}",
13516 t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
13517 )
13518 } else {
13519 "0".to_string()
13520 };
13521 let cov_fn = if t.coverage_functions_found > 0 {
13522 format!(
13523 "{:.1}",
13524 t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
13525 )
13526 } else {
13527 "0".to_string()
13528 };
13529 let cov_branch = if t.coverage_branches_found > 0 {
13530 format!(
13531 "{:.1}",
13532 t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
13533 )
13534 } else {
13535 "0".to_string()
13536 };
13537 let has_cov = !cov_arr.is_empty();
13538 let file_cov_arr = compute_file_cov_arr(&run.per_file_records);
13539 serde_json::json!({
13540 "totals": {
13541 "test_count": total_tests,
13542 "assertions": t.test_assertion_count,
13543 "suites": t.test_suite_count,
13544 "test_files": test_files,
13545 "total_files": t.files_analyzed,
13546 "density_str": format!("{density:.1}"),
13547 "most_tested": most_tested,
13548 "langs_with_tests": langs.len(),
13549 "cov_line": cov_line,
13550 "cov_fn": cov_fn,
13551 "cov_branch": cov_branch,
13552 },
13553 "lang_tests": lang_tests,
13554 "cov": cov_arr,
13555 "cov_tiers": {"high": high, "mid": mid, "low": low},
13556 "file_cov": file_cov_arr,
13557 "has_coverage": has_cov,
13558 "submodules": {},
13559 })
13560}
13561
13562#[allow(clippy::cast_precision_loss)] fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
13564 let mut langs: Vec<&sloc_core::LanguageSummary> = sub
13565 .language_summaries
13566 .iter()
13567 .filter(|l| l.test_count > 0)
13568 .collect();
13569 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13570 let lang_tests: Vec<serde_json::Value> = langs
13571 .iter()
13572 .map(|l| {
13573 let d = if l.code_lines > 0 {
13574 l.test_count as f64 / l.code_lines as f64 * 1000.0
13575 } else {
13576 0.0
13577 };
13578 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
13579 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
13580 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
13581 })
13582 .collect();
13583 let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
13584 let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
13585 let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
13586 let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
13587 let density = if sub.code_lines > 0 {
13588 total_tests as f64 / sub.code_lines as f64 * 1000.0
13589 } else {
13590 0.0
13591 };
13592 let most_tested = langs.first().map_or_else(
13593 || "\u{2014}".to_string(),
13594 |l| l.language.display_name().to_string(),
13595 );
13596 serde_json::json!({
13597 "totals": {
13598 "test_count": total_tests,
13599 "assertions": total_assertions,
13600 "suites": total_suites,
13601 "test_files": test_files_approx,
13602 "total_files": sub.files_analyzed,
13603 "density_str": format!("{density:.1}"),
13604 "most_tested": most_tested,
13605 "langs_with_tests": langs.len(),
13606 "cov_line": "0",
13607 "cov_fn": "0",
13608 "cov_branch": "0",
13609 },
13610 "lang_tests": lang_tests,
13611 "cov": [],
13612 "cov_tiers": {"high": 0, "mid": 0, "low": 0},
13613 "has_coverage": false,
13614 })
13615}
13616
13617fn compute_cov_json_str(run: &AnalysisRun) -> String {
13618 use std::collections::HashMap;
13619 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
13620 for rec in &run.per_file_records {
13621 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
13622 let e = totals.entry(lang.display_name().to_string()).or_default();
13623 e.0 += u64::from(cov.lines_found);
13624 e.1 += u64::from(cov.lines_hit);
13625 }
13626 }
13627 #[allow(clippy::cast_precision_loss)] let mut pairs: Vec<(String, f64)> = totals
13629 .into_iter()
13630 .filter(|(_, (found, _))| *found > 0)
13631 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
13632 .collect();
13633 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
13634 let parts: Vec<String> = pairs
13635 .iter()
13636 .map(|(lang, pct)| {
13637 let name = lang.replace('"', "\\\"");
13638 format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
13639 })
13640 .collect();
13641 format!("[{}]", parts.join(","))
13642}
13643
13644fn compute_cov_tier_json_str(run: &AnalysisRun) -> String {
13645 let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
13646 format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
13647}
13648
13649fn build_scope_entry_for_run(run: &AnalysisRun) -> serde_json::Value {
13650 let mut entry = build_test_scope_entry(run);
13651 if !run.submodule_summaries.is_empty() {
13652 let subs: serde_json::Map<String, serde_json::Value> = run
13653 .submodule_summaries
13654 .iter()
13655 .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
13656 .collect();
13657 entry["submodules"] = serde_json::Value::Object(subs);
13658 }
13659 entry
13660}
13661
13662fn lang_test_entry_json(l: &sloc_core::LanguageSummary) -> String {
13663 let name = l.language.display_name().replace('"', "\\\"");
13664 #[allow(clippy::cast_precision_loss)] let density = if l.code_lines > 0 {
13666 l.test_count as f64 / l.code_lines as f64 * 1000.0
13667 } else {
13668 0.0
13669 };
13670 format!(
13671 r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
13672 name = name,
13673 t = l.test_count,
13674 a = l.test_assertion_count,
13675 s = l.test_suite_count,
13676 c = l.code_lines,
13677 d = density,
13678 f = l.files,
13679 )
13680}
13681
13682fn build_lang_tests_json(run: Option<&AnalysisRun>) -> String {
13683 let Some(r) = run else {
13684 return "[]".to_string();
13685 };
13686 let mut langs: Vec<&sloc_core::LanguageSummary> = r
13687 .totals_by_language
13688 .iter()
13689 .filter(|l| l.test_count > 0)
13690 .collect();
13691 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
13692 let parts: Vec<String> = langs.iter().map(|l| lang_test_entry_json(l)).collect();
13693 format!("[{}]", parts.join(","))
13694}
13695
13696async fn build_scope_data_json(state: &AppState, latest_run: Option<&AnalysisRun>) -> String {
13698 let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
13699 scope_map.insert(
13700 "__all__".to_string(),
13701 latest_run.map_or_else(
13702 || {
13703 serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
13704 "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"\u{2014}",
13705 "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
13706 "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
13707 "has_coverage":false,"submodules":{}})
13708 },
13709 build_test_scope_entry,
13710 ),
13711 );
13712 let all_roots: Vec<String> = {
13713 let reg = state.registry.lock().await;
13714 let mut seen = std::collections::BTreeSet::new();
13715 reg.entries
13716 .iter()
13717 .flat_map(|e| e.input_roots.iter().cloned())
13718 .filter(|r| seen.insert(r.clone()))
13719 .collect()
13720 };
13721 for root in &all_roots {
13722 let json_path = {
13723 let reg = state.registry.lock().await;
13724 reg.entries
13725 .iter()
13726 .find(|e| e.input_roots.iter().any(|r| r == root))
13727 .and_then(|e| e.json_path.clone())
13728 };
13729 let run_for_root: Option<AnalysisRun> = if let Some(p) = json_path {
13730 let json_str = tokio::fs::read_to_string(&p).await.ok();
13731 json_str
13732 .as_deref()
13733 .and_then(|s| serde_json::from_str(s).ok())
13734 } else {
13735 None
13736 };
13737 if let Some(ref run) = run_for_root {
13738 scope_map.insert(root.clone(), build_scope_entry_for_run(run));
13739 }
13740 }
13741 serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
13742}
13743
13744#[allow(clippy::cast_precision_loss)] #[allow(clippy::too_many_lines)] async fn test_metrics_handler(
13748 State(state): State<AppState>,
13749 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
13750) -> Response {
13751 auto_scan_watched_dirs(&state).await;
13752 let watched_dirs_list: Vec<String> = {
13753 let wd = state.watched_dirs.lock().await;
13754 wd.dirs.iter().map(|p| p.display().to_string()).collect()
13755 };
13756 let latest_run: Option<AnalysisRun> = {
13757 let json_path = {
13758 let reg = state.registry.lock().await;
13759 reg.entries.first().and_then(|e| e.json_path.clone())
13760 };
13761 if let Some(p) = json_path {
13762 let json_str = tokio::fs::read_to_string(&p).await.ok();
13763 json_str
13764 .as_deref()
13765 .and_then(|s| serde_json::from_str(s).ok())
13766 } else {
13767 None
13768 }
13769 };
13770
13771 let _lang_tests_json = build_lang_tests_json(latest_run.as_ref());
13773
13774 let cov_json: String = latest_run
13776 .as_ref()
13777 .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
13778 .map_or_else(|| "[]".to_string(), compute_cov_json_str);
13779
13780 let _cov_tier_json: String = latest_run
13782 .as_ref()
13783 .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
13784 .map_or_else(
13785 || r#"{"high":0,"mid":0,"low":0}"#.to_string(),
13786 compute_cov_tier_json_str,
13787 );
13788
13789 let total_tests: u64 = latest_run
13790 .as_ref()
13791 .map_or(0, |r| r.summary_totals.test_count);
13792 let total_assertions: u64 = latest_run
13793 .as_ref()
13794 .map_or(0, |r| r.summary_totals.test_assertion_count);
13795 let total_suites: u64 = latest_run
13796 .as_ref()
13797 .map_or(0, |r| r.summary_totals.test_suite_count);
13798 let total_code: u64 = latest_run
13799 .as_ref()
13800 .map_or(0, |r| r.summary_totals.code_lines);
13801 let workspace_density: f64 = if total_code > 0 {
13802 total_tests as f64 / total_code as f64 * 1000.0
13803 } else {
13804 0.0
13805 };
13806 let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
13807 r.totals_by_language
13808 .iter()
13809 .filter(|l| l.test_count > 0)
13810 .count()
13811 });
13812 let most_tested: String = latest_run
13813 .as_ref()
13814 .and_then(|r| {
13815 r.totals_by_language
13816 .iter()
13817 .filter(|l| l.test_count > 0)
13818 .max_by_key(|l| l.test_count)
13819 })
13820 .map_or_else(
13821 || "\u{2014}".to_string(),
13822 |l| l.language.display_name().to_string(),
13823 );
13824 let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
13825 r.per_file_records
13826 .iter()
13827 .filter(|f| f.raw_line_categories.test_count > 0)
13828 .count() as u64
13829 });
13830 let total_files_analyzed: u64 = latest_run
13831 .as_ref()
13832 .map_or(0, |r| r.summary_totals.files_analyzed);
13833 let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
13834
13835 let cov_line_pct_str: String = latest_run
13837 .as_ref()
13838 .filter(|r| r.summary_totals.coverage_lines_found > 0)
13839 .map_or_else(
13840 || "0".to_string(),
13841 |r| {
13842 format!(
13843 "{:.1}",
13844 r.summary_totals.coverage_lines_hit as f64
13845 / r.summary_totals.coverage_lines_found as f64
13846 * 100.0
13847 )
13848 },
13849 );
13850 let cov_fn_pct_str: String = latest_run
13851 .as_ref()
13852 .filter(|r| r.summary_totals.coverage_functions_found > 0)
13853 .map_or_else(
13854 || "0".to_string(),
13855 |r| {
13856 format!(
13857 "{:.1}",
13858 r.summary_totals.coverage_functions_hit as f64
13859 / r.summary_totals.coverage_functions_found as f64
13860 * 100.0
13861 )
13862 },
13863 );
13864 let cov_branch_pct_str: String = latest_run
13865 .as_ref()
13866 .filter(|r| r.summary_totals.coverage_branches_found > 0)
13867 .map_or_else(
13868 || "0".to_string(),
13869 |r| {
13870 format!(
13871 "{:.1}",
13872 r.summary_totals.coverage_branches_hit as f64
13873 / r.summary_totals.coverage_branches_found as f64
13874 * 100.0
13875 )
13876 },
13877 );
13878
13879 let cov_no_data_notice = if has_coverage {
13880 String::new()
13881 } else {
13882 String::from(
13883 r#"<div class="empty-state" style="margin-bottom:18px;padding:20px 24px;">
13884<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>
13885<div style="display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:6px 4px;margin-bottom:10px;">
13886 <span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-right:4px;">Supported formats</span>
13887 <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>
13888 <span style="color:var(--muted);font-size:12px;">·</span>
13889 <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>
13890 <span style="color:var(--muted);font-size:12px;">·</span>
13891 <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>
13892 <span style="color:var(--muted);font-size:12px;">·</span>
13893 <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>
13894 <span style="color:var(--muted);font-size:12px;">·</span>
13895 <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>
13896</div>
13897<div style="font-size:12px;color:var(--muted);">Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
13898</div>"#,
13899 )
13900 };
13901
13902 let workspace_density_str = format!("{workspace_density:.1}");
13903 let nonce = &csp_nonce;
13904 let toast_assets = sloc_toast_assets(nonce);
13905 let version = env!("CARGO_PKG_VERSION");
13906
13907 let watched_dirs_html: String = if state.server_mode {
13910 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()
13911 } else {
13912 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
13913 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
13914 .to_string()
13915 } else {
13916 watched_dirs_list
13917 .iter()
13918 .fold(String::new(), |mut s, d| {
13919 use std::fmt::Write as _;
13920 let escaped =
13921 d.replace('&', "&").replace('"', """).replace('<', "<");
13922 write!(
13923 s,
13924 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>"#
13925 ).expect("write to String is infallible");
13926 s
13927 })
13928 };
13929 format!(
13930 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>"#
13931 )
13932 };
13933
13934 let scope_data_json = build_scope_data_json(&state, latest_run.as_ref()).await;
13936
13937 let html = format!(
13938 r#"<!doctype html>
13939<html lang="en">
13940<head>
13941 <meta charset="utf-8" />
13942 <meta name="viewport" content="width=device-width, initial-scale=1" />
13943 <title>OxideSLOC | Test Metrics</title>
13944 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
13945 <style nonce="{nonce}">
13946 :root {{
13947 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
13948 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
13949 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
13950 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
13951 --info-bg:#eef3ff; --info-text:#4467d8;
13952 }}
13953 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
13954 *{{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;}}
13955 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
13956 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
13957 .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;}}
13958 @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));}}}}
13959 .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);}}
13960 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
13961 .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));}}
13962 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
13963 .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;}}
13964 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
13965 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
13966 @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; }} }}
13967 .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;}}
13968 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
13969 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
13970 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
13971 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
13972 .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;}}
13973 .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;}}
13974 .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;}}
13975 .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;}}
13976 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
13977 .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);}}
13978 .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;}}
13979 .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;}}
13980 .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;}}
13981 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
13982 .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;}}
13983 .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);}}
13984 .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;}}
13985 .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;}}
13986 .tz-select:focus{{border-color:var(--oxide);}}
13987 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
13988 @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
13989 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
13990 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
13991 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
13992 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
13993 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
13994 .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);}}
13995 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
13996 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
13997 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
13998 .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;}}
13999 .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;}}
14000 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
14001 .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
14002 .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);}}
14003 .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
14004 .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
14005 @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
14006 .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
14007 .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
14008 .chart-canvas-wrap{{position:relative;height:280px;}}
14009 .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;}}
14010 .chart-no-data svg{{opacity:0.35;}}
14011 .chart-no-data-title{{font-weight:700;font-size:13px;color:var(--muted-2);}}
14012 .chart-no-data-hint{{font-size:11px;color:var(--muted);text-align:center;max-width:220px;line-height:1.5;}}
14013 .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
14014 .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;}}
14015 .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;}}
14016 .data-table tr:last-child td{{border-bottom:none;}}
14017 .data-table tbody tr:hover td{{background:var(--surface-2);}}
14018 .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
14019 .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
14020 .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
14021 .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
14022 .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;}}
14023 .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
14024 .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);}}
14025 .cov-gauge-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
14026 .cov-gauge-card:hover .cov-gauge-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
14027 .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
14028 .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
14029 .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
14030 .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
14031 .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
14032 @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
14033 .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
14034 .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;}}
14035 .chart-select:focus{{border-color:var(--accent);}}
14036 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
14037 .trend-canvas-wrap{{position:relative;height:260px;}}
14038 .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;}}
14039 .trend-controls-bar label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
14040 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
14041 .site-footer a{{color:var(--muted);}}
14042 body.dark-theme .chart-box{{border-color:var(--line-strong);}}
14043 .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;}}
14044 .btn:hover{{background:var(--surface-2);}}
14045 .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;}}
14046 .export-btn:hover{{background:var(--line);}}
14047 .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
14048 /* Page-level export controls (Scope toolbar, right-aligned) — identical style to View Reports */
14049 .export-group{{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}}
14050 .scope-export{{margin-left:auto;}}
14051 body.pdf-mode .export-group{{display:none!important;}}
14052 @media (max-width:720px){{.scope-export{{margin-left:0;width:100%;}}}}
14053 .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;}}
14054 .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
14055 .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
14056 .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;}}
14057 .scope-sel:focus{{border-color:var(--accent);}}
14058 body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
14059 .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;}}
14060 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
14061 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
14062 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
14063 .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;}}
14064 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
14065 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
14066 .watched-chip-rm:hover{{color:var(--oxide);}}
14067 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
14068 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
14069 .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
14070 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
14071 .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
14072 .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
14073 .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;}}
14074 .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
14075 .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
14076 .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
14077 .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
14078 .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;}}
14079 .cov-file-search:focus{{border-color:var(--accent);}}
14080 .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
14081 .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;}}
14082 body.dark-theme .cov-file-search{{background:var(--surface);}}
14083 .chart-box-header{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
14084 .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;}}
14085 .chart-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
14086 .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;}}
14087 .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);}}
14088 .chart-modal-title{{font-size:15px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:var(--text);margin:0 0 2px;display:block;}}
14089 .chart-modal-subtitle{{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 16px;display:block;letter-spacing:.02em;}}
14090 .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;}}
14091 .chart-modal-close:hover{{opacity:.7;}}
14092 body.dark-theme .chart-modal{{background:var(--surface);}}
14093 </style>
14094</head>
14095<body>
14096 <div class="background-watermarks" aria-hidden="true">
14097 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14098 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14099 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14100 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14101 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14102 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14103 </div>
14104 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
14105 <div class="top-nav">
14106 <div class="top-nav-inner">
14107 <a class="brand" href="/">
14108 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
14109 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
14110 </a>
14111 <div class="nav-right">
14112 <a class="nav-pill" href="/">Home</a>
14113 <div class="nav-dropdown">
14114 <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>
14115 <div class="nav-dropdown-menu">
14116 <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>
14117 </div>
14118 </div>
14119 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
14120 <a class="nav-pill" href="/test-metrics" style="background:rgba(255,255,255,0.22);">Test Metrics</a>
14121 <div class="nav-dropdown">
14122 <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>
14123 <div class="nav-dropdown-menu">
14124 <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>
14125 </div>
14126 </div>
14127 <div class="server-status-wrap" id="server-status-wrap">
14128 <div class="nav-pill server-online-pill" id="server-status-pill">
14129 <span class="status-dot" id="status-dot"></span>
14130 <span id="server-status-label">Server</span>
14131 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
14132 </div>
14133 <div class="server-status-tip">
14134 OxideSLOC is running — accessible on your network.
14135 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
14136 </div>
14137 </div>
14138 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
14139 <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>
14140 </button>
14141 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
14142 <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>
14143 <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>
14144 </button>
14145 </div>
14146 </div>
14147 </div>
14148
14149 <div class="page">
14150 {watched_dirs_html}
14151 <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
14152 <div class="scan-overlay-card">
14153 <div class="scan-spinner"></div>
14154 <div class="scan-overlay-text">Scanning folder…</div>
14155 <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
14156 </div>
14157 </div>
14158 <style>
14159 .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);}}
14160 .scan-overlay.active{{display:flex;}}
14161 .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;}}
14162 .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;}}
14163 @keyframes scanSpin{{to{{transform:rotate(360deg);}}}}
14164 .scan-overlay-text{{font-size:15px;font-weight:800;color:var(--text);}}
14165 .scan-overlay-sub{{font-size:12px;color:var(--muted);line-height:1.5;}}
14166 </style>
14167 <div class="scope-bar">
14168 <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>
14169 <span class="scope-label">Scope</span>
14170 <div class="scope-sel-wrap">
14171 <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
14172 <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);">
14173 <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>
14174 <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
14175 </div>
14176 </div>
14177 <!-- Page-level export: covers the whole page (Test Metrics + LCOV Coverage Summary) for the selected scope. -->
14178 <div class="export-group scope-export" id="tm-export-group">
14179 <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)">
14180 <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>
14181 Export Excel
14182 </button>
14183 <button type="button" class="export-btn" id="tm-export-png-btn" title="Save the whole page's charts as a PNG image">
14184 <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>
14185 Export PNG
14186 </button>
14187 <button type="button" class="export-btn" id="tm-export-pdf-btn" title="Export the whole page as a printable PDF report">
14188 <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>
14189 Export PDF
14190 </button>
14191 </div>
14192 </div>
14193 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
14194 <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>
14195 <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>
14196 <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>
14197 <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>
14198 </div>
14199 <div class="summary-strip" style="grid-template-columns:repeat(4,1fr);">
14200 <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>
14201 <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>
14202 <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>
14203 <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>
14204 </div>
14205
14206 <div class="panel" id="viz-panel">
14207 <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">Visualizations</div>
14208
14209 <div class="chart-box" style="margin-bottom:18px;">
14210 <div class="chart-box-header">
14211 <div class="chart-box-title" style="margin-bottom:0;">Test Count Trend</div>
14212 <div style="display:flex;gap:8px;align-items:center;">
14213 <button class="chart-expand-btn" id="multi-compare-trend-btn" title="Open all scans in Multi-Scan Timeline" style="display:none;">⇌ Multi-Timeline</button>
14214 <button class="chart-expand-btn" id="trend-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
14215 </div>
14216 </div>
14217 <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>
14218 <div class="trend-controls-bar">
14219 <label>Y Metric:
14220 <select class="chart-select" id="tm-trend-y">
14221 <option value="test_count" selected>Test Definitions</option>
14222 <option value="code_lines">Code Lines</option>
14223 </select>
14224 </label>
14225 <label>X Axis:
14226 <select class="chart-select" id="tm-trend-x">
14227 <option value="commit" selected>By Commit</option>
14228 <option value="time">By Time</option>
14229 </select>
14230 </label>
14231 <label id="tm-sub-label" style="display:none;">Submodule:
14232 <select class="chart-select" id="tm-trend-sub">
14233 <option value="">All (project total)</option>
14234 </select>
14235 </label>
14236 <label>Chart Size:
14237 <select class="chart-select" id="tm-trend-size">
14238 <option value="200">Compact</option>
14239 <option value="260" selected>Normal</option>
14240 <option value="360">Large</option>
14241 </select>
14242 </label>
14243 </div>
14244 <div class="chart-canvas-wrap trend-canvas-wrap" id="trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
14245 <div id="trend-empty" class="empty-state" style="display:none;">No historical test data found. Run more scans to see trends.</div>
14246 </div>
14247
14248 <div class="chart-row">
14249 <div class="chart-box">
14250 <div class="chart-box-header">
14251 <div class="chart-box-title" style="margin-bottom:0;">Test Definitions by Language</div>
14252 <button class="chart-expand-btn" id="tests-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
14253 </div>
14254 <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
14255 <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>
14256 </div>
14257 <div class="chart-box">
14258 <div class="chart-box-header">
14259 <div class="chart-box-title" style="margin-bottom:0;">Test Density (per 1,000 code lines)</div>
14260 <button class="chart-expand-btn" id="density-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
14261 </div>
14262 <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
14263 <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>
14264 </div>
14265 </div>
14266
14267 <div class="chart-row">
14268 <div class="chart-box">
14269 <div class="chart-box-header">
14270 <div class="chart-box-title" style="margin-bottom:0;">Assertions by Language</div>
14271 <button class="chart-expand-btn" id="assertions-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
14272 </div>
14273 <div class="chart-canvas-wrap"><canvas id="canvas-assertions"></canvas></div>
14274 <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>
14275 </div>
14276 <div class="chart-box" id="suites-chart-box">
14277 <div class="chart-box-header">
14278 <div class="chart-box-title" style="margin-bottom:0;">Test Suites by Language</div>
14279 <button class="chart-expand-btn" id="suites-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
14280 </div>
14281 <div class="chart-canvas-wrap"><canvas id="canvas-suites"></canvas></div>
14282 <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>
14283 </div>
14284 </div>
14285
14286 <div class="chart-row">
14287 <div class="chart-box">
14288 <div class="chart-box-title">Test Files Breakdown</div>
14289 <div class="chart-canvas-wrap" style="height:260px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-files"></canvas></div>
14290 <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>
14291 </div>
14292 <div class="chart-box">
14293 <div class="chart-box-title">Test Composition</div>
14294 <p style="font-size:11px;color:var(--muted);margin:0 0 10px;">Total counts: test functions, assertions, and suites workspace-wide.</p>
14295 <div class="chart-canvas-wrap"><canvas id="canvas-composition"></canvas></div>
14296 <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>
14297 </div>
14298 </div>
14299 </div>
14300
14301 <div class="panel">
14302 <h1>Test Metrics</h1>
14303 <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>
14304
14305 <div class="section-header">Language Breakdown</div>
14306 {cov_no_data_notice}
14307 <div style="overflow-x:auto;">
14308 <table class="data-table" id="lang-table">
14309 <thead><tr>
14310 <th>Language</th>
14311 <th class="num">Test Fns</th>
14312 <th class="num">Assertions</th>
14313 <th class="num">Suites</th>
14314 <th class="num">Code Lines</th>
14315 <th class="num">Files</th>
14316 <th class="num">Density / 1K</th>
14317 <th>Relative Density</th>
14318 </tr></thead>
14319 <tbody id="lang-tbody"></tbody>
14320 </table>
14321 </div>
14322 </div>
14323
14324 <div class="panel" id="cov-panel" style="display:none;">
14325 <div class="section-header" style="margin-top:0;padding-top:0;border-top:none;">LCOV Coverage Summary</div>
14326 <div class="cov-gauge-row" id="cov-gauges">
14327 <div class="cov-gauge-card">
14328 <div class="cov-gauge-label">Line Coverage</div>
14329 <div class="cov-gauge-val" id="cov-line-val" style="color:#2a6846;">{cov_line_pct_str}%</div>
14330 <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
14331 <div class="cov-gauge-sub">Lines hit / instrumented</div>
14332 <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>
14333 </div>
14334 <div class="cov-gauge-card">
14335 <div class="cov-gauge-label">Function Coverage</div>
14336 <div class="cov-gauge-val" id="cov-fn-val" style="color:#1a6b96;">{cov_fn_pct_str}%</div>
14337 <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
14338 <div class="cov-gauge-sub">Functions hit / found</div>
14339 <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>
14340 </div>
14341 <div class="cov-gauge-card">
14342 <div class="cov-gauge-label">Branch Coverage</div>
14343 <div class="cov-gauge-val" id="cov-branch-val" style="color:#7a4fa0;">{cov_branch_pct_str}%</div>
14344 <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
14345 <div class="cov-gauge-sub">Branches hit / found</div>
14346 <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>
14347 </div>
14348 </div>
14349 <div class="chart-row">
14350 <div class="chart-box">
14351 <div class="chart-box-title">Line Coverage % by Language</div>
14352 <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
14353 </div>
14354 <div class="chart-box">
14355 <div class="chart-box-title">Coverage Tier Distribution</div>
14356 <div class="chart-canvas-wrap" style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="canvas-cov-tiers"></canvas></div>
14357 </div>
14358 </div>
14359
14360 <div class="section-header" style="margin-top:24px;">Coverage File Detail</div>
14361 <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>
14362 <div class="cov-file-toolbar">
14363 <div class="cov-filter-tabs" id="cov-filter-tabs">
14364 <button class="cov-tab active" data-tier="all">All</button>
14365 <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
14366 <button class="cov-tab" data-tier="low">Low (<50%)</button>
14367 <button class="cov-tab" data-tier="mid">Moderate (50-79%)</button>
14368 <button class="cov-tab" data-tier="high">High (≥80%)</button>
14369 </div>
14370 <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename…">
14371 </div>
14372 <div style="overflow-x:auto;">
14373 <table class="data-table" id="cov-file-table">
14374 <thead><tr>
14375 <th>File</th>
14376 <th>Lang</th>
14377 <th class="num">Line %</th>
14378 <th class="num">Lines Hit / Found</th>
14379 <th class="num">Fn %</th>
14380 <th class="num">Fns Hit / Found</th>
14381 </tr></thead>
14382 <tbody id="cov-file-tbody"></tbody>
14383 </table>
14384 </div>
14385 <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>
14386 <div id="cov-file-count" style="text-align:right;font-size:11px;color:var(--muted);margin-top:8px;"></div>
14387 </div>
14388
14389 </div>
14390
14391 <footer class="site-footer">
14392 local code analysis - metrics, history and reports
14393 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{version} — Mode: Server</em>
14394 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
14395 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
14396 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
14397 · <a href="/api-docs" rel="noopener">REST API</a>
14398 </footer>
14399
14400 <script nonce="{nonce}">
14401 (function() {{
14402 // Theme
14403 var b = document.body;
14404 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
14405 var tgl = document.getElementById('theme-toggle');
14406 if (tgl) tgl.addEventListener('click', function() {{
14407 var d = b.classList.toggle('dark-theme');
14408 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
14409 }});
14410
14411 // Watermarks
14412 (function() {{
14413 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
14414 if (!wms.length) return;
14415 var placed = [];
14416 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;}}
14417 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];}}
14418 var half=Math.floor(wms.length/2);
14419 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;}});
14420 }})();
14421
14422 // Code particles
14423 (function() {{
14424 var container = document.getElementById('code-particles');
14425 if (!container) return;
14426 var snippets = ['#[test]','def test_','@Test','it(\'should','func Test','describe(','TEST(','test_that(','expect(','assert_eq!','@Fact','it \"passes\"','test {{','Describe'];
14427 for (var i = 0; i < 36; i++) {{
14428 (function(idx) {{
14429 var el = document.createElement('span');
14430 el.className = 'code-particle';
14431 el.textContent = snippets[idx % snippets.length];
14432 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
14433 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
14434 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.09 + 0.06).toFixed(3);
14435 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';
14436 container.appendChild(el);
14437 }})(i);
14438 }}
14439 }})();
14440
14441 // Settings modal
14442 (function() {{
14443 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'}}];
14444 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);}});}}
14445 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
14446 var btn=document.getElementById('settings-btn');if(!btn)return;
14447 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
14448 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>';
14449 document.body.appendChild(m);
14450 var g=document.getElementById('scheme-grid');
14451 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);}});
14452 var cl=document.getElementById('settings-close');
14453 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');}});
14454 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
14455 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
14456 }})();
14457
14458 // Watched folder picker
14459 (function(){{
14460 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');}};
14461 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);
14462 }})();
14463 (function() {{
14464 var btn = document.getElementById('add-watched-btn');
14465 if (!btn) return;
14466 btn.addEventListener('click', function() {{
14467 fetch('/pick-directory?kind=reports')
14468 .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
14469 .then(function(data) {{
14470 if (!data.cancelled && data.selected_path) {{
14471 var form = document.createElement('form');
14472 form.method = 'POST';
14473 form.action = '/watched-dirs/add';
14474 var ri = document.createElement('input');
14475 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
14476 var fi = document.createElement('input');
14477 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
14478 form.appendChild(ri); form.appendChild(fi);
14479 document.body.appendChild(form);
14480 if (window.__scanOverlay) window.__scanOverlay();
14481 form.submit();
14482 }}
14483 }})
14484 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
14485 }});
14486 }})();
14487 }})();
14488 </script>
14489
14490 <script src="/static/chart.js" nonce="{nonce}"></script>
14491 <script nonce="{nonce}">
14492 (function() {{
14493 var SCOPE_DATA = {scope_data_json};
14494 var currentRoot = '__all__';
14495 var currentSub = '';
14496 var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
14497 var assertionsChart = null, suitesChart = null, filesChart = null, compositionChart = null;
14498 var ALL_CHARTS = [];
14499 var currentLangTests = [];
14500 var currentTrendPts = [];
14501
14502 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();}}
14503 function fmtFull(n){{return Number(n).toLocaleString();}}
14504 function isDark(){{return document.body.classList.contains('dark-theme');}}
14505 function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
14506 function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
14507 var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
14508
14509 function makeDlPlugin(fmtFn, anchor) {{
14510 return {{
14511 afterDatasetsDraw: function(chart) {{
14512 var ctx = chart.ctx;
14513 var tc = txtClr();
14514 chart.data.datasets.forEach(function(ds, di) {{
14515 var meta = chart.getDatasetMeta(di);
14516 meta.data.forEach(function(el, idx) {{
14517 var label = fmtFn(ds.data[idx], di, idx);
14518 if (label == null || label === '') return;
14519 ctx.save();
14520 ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
14521 ctx.fillStyle = tc;
14522 if (anchor === 'top') {{
14523 ctx.textAlign = 'center';
14524 ctx.textBaseline = 'bottom';
14525 ctx.fillText(String(label), el.x, el.y - 5);
14526 }} else {{
14527 ctx.textAlign = 'left';
14528 ctx.textBaseline = 'middle';
14529 ctx.fillText(String(label), el.x + 5, el.y);
14530 }}
14531 ctx.restore();
14532 }});
14533 }});
14534 }}
14535 }};
14536 }}
14537
14538 // Cursor: pointer over chart data, default over empty chart area.
14539 function chartCursor(e, els) {{
14540 var t = e.native && e.native.target;
14541 if (t) t.style.cursor = els.length ? 'pointer' : 'default';
14542 }}
14543 Chart.defaults.onHover = chartCursor; // applies to every chart on this page
14544
14545 // ── Global bar hover emphasis ──────────────────────────────────────────────
14546 // Doughnuts pop via hoverOffset; bars had no per-bar hover feedback (fading the
14547 // *other* bars does nothing when there is only one). Give every bar chart a
14548 // built-in "pop": the hovered bar brightens, lifts with a rounded outline, and
14549 // animates via the fast active transition. Applied globally through a plugin so
14550 // it covers all current and future bar charts on the page.
14551 function tmLighten(c, amt) {{
14552 if (typeof c === 'string' && c.charAt(0) === '#' && c.length === 7) {{
14553 var n = parseInt(c.slice(1), 16), r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
14554 r = Math.round(r + (255 - r) * amt);
14555 g = Math.round(g + (255 - g) * amt);
14556 b = Math.round(b + (255 - b) * amt);
14557 return 'rgb(' + r + ',' + g + ',' + b + ')';
14558 }}
14559 return c;
14560 }}
14561 var tmBarHoverEmphasis = {{
14562 id: 'tmBarHoverEmphasis',
14563 beforeInit: function(chart) {{
14564 if (!chart.config || chart.config.type !== 'bar') return;
14565 (chart.data.datasets || []).forEach(function(ds) {{
14566 var bg = ds.backgroundColor;
14567 if (ds.hoverBackgroundColor == null) {{
14568 ds.hoverBackgroundColor = Array.isArray(bg)
14569 ? bg.map(function(c) {{ return tmLighten(c, 0.24); }})
14570 : tmLighten(bg, 0.24);
14571 }}
14572 if (ds.hoverBorderColor == null) {{
14573 ds.hoverBorderColor = isDark() ? 'rgba(245,236,230,0.9)' : 'rgba(67,52,45,0.82)';
14574 }}
14575 if (ds.hoverBorderWidth == null) ds.hoverBorderWidth = 3;
14576 }});
14577 }}
14578 }};
14579 Chart.register(tmBarHoverEmphasis);
14580 // Quick, smooth tween when a bar enters/leaves the hovered (active) state.
14581 try {{
14582 Chart.defaults.transitions.active = Chart.defaults.transitions.active || {{}};
14583 Chart.defaults.transitions.active.animation = Chart.defaults.transitions.active.animation || {{}};
14584 Chart.defaults.transitions.active.animation.duration = 260;
14585 }} catch (e) {{}}
14586
14587 // Plugin: draws % labels inside each doughnut slice.
14588 var donutPctPlugin = {{
14589 afterDatasetsDraw: function(chart) {{
14590 var ctx = chart.ctx;
14591 chart.data.datasets.forEach(function(ds, di) {{
14592 var meta = chart.getDatasetMeta(di);
14593 if (meta.hidden) return;
14594 var total = 0;
14595 for (var k = 0; k < ds.data.length; k++) total += (ds.data[k] || 0);
14596 if (!total) return;
14597 meta.data.forEach(function(arc, i) {{
14598 if (arc.hidden) return;
14599 var val = ds.data[i] || 0;
14600 var pct = val / total * 100;
14601 if (pct < 3) return;
14602 var midAngle = (arc.startAngle + arc.endAngle) / 2;
14603 var midR = (arc.innerRadius + arc.outerRadius) / 2;
14604 var tx = arc.x + midR * Math.cos(midAngle);
14605 var ty = arc.y + midR * Math.sin(midAngle);
14606 ctx.save();
14607 ctx.textAlign = 'center';
14608 ctx.textBaseline = 'middle';
14609 ctx.font = 'bold 13px Inter,ui-sans-serif,sans-serif';
14610 ctx.shadowColor = 'rgba(0,0,0,0.45)';
14611 ctx.shadowBlur = 3;
14612 ctx.fillStyle = '#fff';
14613 ctx.fillText(pct.toFixed(0) + '%', tx, ty);
14614 ctx.restore();
14615 }});
14616 }});
14617 }}
14618 }};
14619
14620 function makeTmOverlay(title, subtitle, h) {{
14621 var overlay = document.createElement('div');
14622 overlay.className = 'chart-modal-overlay';
14623 var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
14624 var ch = Math.min(h || 560, maxH);
14625 var subHtml = subtitle ? '<span class="chart-modal-subtitle">' + subtitle + '</span>' : '';
14626 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>';
14627 document.body.appendChild(overlay);
14628 overlay.querySelector('.chart-modal-close').addEventListener('click', function(){{ document.body.removeChild(overlay); }});
14629 overlay.addEventListener('click', function(e){{ if (e.target === overlay) document.body.removeChild(overlay); }});
14630 return document.getElementById('tm-modal-canvas');
14631 }}
14632
14633 function getDataset() {{
14634 var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
14635 if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
14636 return r;
14637 }}
14638 function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
14639
14640 function showNoData(id, show) {{
14641 var el = document.getElementById(id);
14642 if (!el) return;
14643 var wrap = el.previousElementSibling;
14644 el.style.display = show ? '' : 'none';
14645 if (wrap && wrap.classList.contains('chart-canvas-wrap')) wrap.style.display = show ? 'none' : '';
14646 }}
14647
14648 // Shared hover treatment for every single-series bar/doughnut chart on this page:
14649 // emphasise the hovered bar/arc and fade the rest, mirroring the highlight+fade
14650 // treatment used by the language charts on the scan results page.
14651 function tmFadeColor(c) {{
14652 if (typeof c === 'string' && c.charAt(0) === '#' && c.length === 7) return c + '3D';
14653 return c;
14654 }}
14655 function tmApplyFade(chart, activeIdx) {{
14656 var ds = chart.data.datasets[0];
14657 if (!ds._baseBg) ds._baseBg = ds.backgroundColor.slice();
14658 if (activeIdx == null) {{
14659 ds.backgroundColor = ds._baseBg.slice();
14660 }} else {{
14661 ds.backgroundColor = ds._baseBg.map(function(c, i) {{
14662 return i === activeIdx ? ds._baseBg[i] : tmFadeColor(ds._baseBg[i]);
14663 }});
14664 }}
14665 }}
14666 function tmFadeHover(e, active, chart) {{
14667 var t = e.native && e.native.target;
14668 if (t) t.style.cursor = active.length ? 'pointer' : 'default';
14669 var idx = active.length ? active[0].index : null;
14670 if (chart._fadeIdx === idx) return;
14671 chart._fadeIdx = idx;
14672 tmApplyFade(chart, idx);
14673 // 'active' mode tweens the fade + the hovered bar's pop via the fast active
14674 // transition (doughnuts keep their own hoverOffset motion regardless).
14675 chart.update('active');
14676 }}
14677 // Legend hover on a doughnut should highlight+fade exactly like hovering the arc.
14678 function tmDoughnutLegendHover(e, item, leg) {{
14679 var ch = leg.chart;
14680 var t = e.native && e.native.target;
14681 if (t) t.style.cursor = 'pointer';
14682 ch._fadeIdx = item.index;
14683 ch.setActiveElements([{{ datasetIndex: 0, index: item.index }}]);
14684 ch.tooltip.setActiveElements([{{ datasetIndex: 0, index: item.index }}], {{ x: 0, y: 0 }});
14685 tmApplyFade(ch, item.index);
14686 ch.update();
14687 }}
14688 function tmDoughnutLegendLeave(e, item, leg) {{
14689 var ch = leg.chart;
14690 var t = e.native && e.native.target;
14691 if (t) t.style.cursor = 'default';
14692 ch._fadeIdx = null;
14693 ch.setActiveElements([]);
14694 ch.tooltip.setActiveElements([], {{}});
14695 tmApplyFade(ch, null);
14696 ch.update('none');
14697 }}
14698
14699 function renderTestCharts(D) {{
14700 currentLangTests = D || [];
14701 testsChart = destroyChart(testsChart);
14702 densityChart = destroyChart(densityChart);
14703 if (!D || !D.length) {{
14704 showNoData('no-data-tests', true);
14705 showNoData('no-data-density', true);
14706 return;
14707 }}
14708 showNoData('no-data-tests', false);
14709 showNoData('no-data-density', false);
14710 var top15 = D.slice(0, 15);
14711 var canvas1 = document.getElementById('canvas-tests');
14712 if (canvas1) {{
14713 testsChart = new Chart(canvas1, {{
14714 type: 'bar',
14715 data: {{
14716 labels: top15.map(function(d){{ return d.lang; }}),
14717 datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
14718 }},
14719 options: {{
14720 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
14721 layout: {{ padding: {{ right: 64 }} }},
14722 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14723 scales: {{
14724 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14725 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14726 }}
14727 }},
14728 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14729 }});
14730 ALL_CHARTS.push(testsChart);
14731 }}
14732 var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
14733 var canvas2 = document.getElementById('canvas-density');
14734 if (canvas2) {{
14735 densityChart = new Chart(canvas2, {{
14736 type: 'bar',
14737 data: {{
14738 labels: topD.map(function(d){{ return d.lang; }}),
14739 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 }}]
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 ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
14745 scales: {{
14746 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
14747 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14748 }}
14749 }},
14750 plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
14751 }});
14752 ALL_CHARTS.push(densityChart);
14753 }}
14754 }}
14755
14756 function renderAssertionsChart(D) {{
14757 assertionsChart = destroyChart(assertionsChart);
14758 if (!D || !D.length) {{ showNoData('no-data-assertions', true); return; }}
14759 var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
14760 var canvas = document.getElementById('canvas-assertions');
14761 if (!canvas || !top15.length) {{ showNoData('no-data-assertions', true); return; }}
14762 showNoData('no-data-assertions', false);
14763 assertionsChart = new Chart(canvas, {{
14764 type: 'bar',
14765 data: {{
14766 labels: top15.map(function(d){{ return d.lang; }}),
14767 datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
14768 }},
14769 options: {{
14770 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
14771 layout: {{ padding: {{ right: 64 }} }},
14772 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14773 scales: {{
14774 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14775 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14776 }}
14777 }},
14778 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14779 }});
14780 ALL_CHARTS.push(assertionsChart);
14781 }}
14782
14783 function renderSuitesChart(D) {{
14784 suitesChart = destroyChart(suitesChart);
14785 if (!D || !D.length) {{ showNoData('no-data-suites', true); return; }}
14786 var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
14787 var canvas = document.getElementById('canvas-suites');
14788 if (!canvas || !top15.length) {{ showNoData('no-data-suites', true); return; }}
14789 showNoData('no-data-suites', false);
14790 suitesChart = new Chart(canvas, {{
14791 type: 'bar',
14792 data: {{
14793 labels: top15.map(function(d){{ return d.lang; }}),
14794 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 }}]
14795 }},
14796 options: {{
14797 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
14798 layout: {{ padding: {{ right: 64 }} }},
14799 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
14800 scales: {{
14801 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
14802 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14803 }}
14804 }},
14805 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
14806 }});
14807 ALL_CHARTS.push(suitesChart);
14808 }}
14809
14810 function renderFilesChart(totals) {{
14811 filesChart = destroyChart(filesChart);
14812 var canvas = document.getElementById('canvas-files');
14813 if (!canvas) return;
14814 var testF = totals.test_files || 0;
14815 var totalF = totals.total_files || 0;
14816 var nonTest = Math.max(0, totalF - testF);
14817 if (totalF === 0) {{ showNoData('no-data-files', true); return; }}
14818 showNoData('no-data-files', false);
14819 var dark = isDark();
14820 filesChart = new Chart(canvas, {{
14821 type: 'doughnut',
14822 data: {{
14823 labels: ['Test Files', 'Non-Test Files'],
14824 datasets: [{{ data: [testF, nonTest], backgroundColor: ['#C45C10', dark ? '#524238' : '#e6d0bf'], borderWidth: 2, borderColor: dark ? '#1e1e1e' : '#f5efe8', hoverOffset: 14 }}]
14825 }},
14826 options: {{
14827 responsive: true, maintainAspectRatio: false, cutout: '62%',
14828 onHover: tmFadeHover,
14829 plugins: {{
14830 legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16,
14831 generateLabels: function(chart) {{
14832 var ds = chart.data.datasets[0];
14833 var tot = ds.data.reduce(function(a,b){{return a+(b||0);}}, 0);
14834 return chart.data.labels.map(function(lbl, i) {{
14835 var val = ds.data[i] || 0;
14836 var pct = tot > 0 ? (val / tot * 100).toFixed(0) : '0';
14837 return {{
14838 text: lbl + ' ' + fmtFull(val) + ' (' + pct + '%)',
14839 fillStyle: ds.backgroundColor[i],
14840 strokeStyle: ds.borderColor,
14841 lineWidth: ds.borderWidth,
14842 hidden: false,
14843 index: i,
14844 datasetIndex: 0
14845 }};
14846 }});
14847 }}
14848 }},
14849 onHover: tmDoughnutLegendHover,
14850 onLeave: tmDoughnutLegendLeave
14851 }},
14852 tooltip: {{ callbacks: {{ label: function(ctx) {{
14853 var v = ctx.parsed, pct = totalF > 0 ? (v / totalF * 100).toFixed(1) : '0';
14854 return ' ' + fmtFull(v) + ' files (' + pct + '%)';
14855 }} }} }}
14856 }}
14857 }},
14858 plugins: [donutPctPlugin]
14859 }});
14860 ALL_CHARTS.push(filesChart);
14861 }}
14862
14863 function renderCompositionChart(totals) {{
14864 compositionChart = destroyChart(compositionChart);
14865 var canvas = document.getElementById('canvas-composition');
14866 if (!canvas) return;
14867 var tc = totals.test_count || 0, ac = totals.assertions || 0, sc = totals.suites || 0;
14868 if (tc === 0 && ac === 0 && sc === 0) {{ showNoData('no-data-composition', true); return; }}
14869 showNoData('no-data-composition', false);
14870 compositionChart = new Chart(canvas, {{
14871 type: 'bar',
14872 data: {{
14873 labels: ['Test Functions', 'Assertions', 'Test Suites'],
14874 datasets: [{{ label: 'Count', data: [tc, ac, sc], backgroundColor: ['#C45C10', '#2A6846', '#4472C4'], borderRadius: 6 }}]
14875 }},
14876 options: {{
14877 responsive: true, maintainAspectRatio: false,
14878 onHover: tmFadeHover,
14879 layout: {{ padding: {{ top: 22 }} }},
14880 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y); }} }} }} }},
14881 scales: {{
14882 x: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }},
14883 y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
14884 }}
14885 }},
14886 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top')]
14887 }});
14888 ALL_CHARTS.push(compositionChart);
14889 }}
14890
14891 function renderCovCharts(covD, tiers) {{
14892 covChart = destroyChart(covChart);
14893 tierChart = destroyChart(tierChart);
14894 var covCanvas = document.getElementById('canvas-cov');
14895 if (covCanvas && covD && covD.length) {{
14896 covChart = new Chart(covCanvas, {{
14897 type: 'bar',
14898 data: {{
14899 labels: covD.map(function(d){{ return d.lang; }}),
14900 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 }}]
14901 }},
14902 options: {{
14903 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
14904 layout: {{ padding: {{ right: 52 }} }},
14905 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
14906 scales: {{
14907 x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
14908 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
14909 }}
14910 }},
14911 plugins: [makeDlPlugin(function(v){{ return Number(v).toFixed(1) + '%'; }}, 'end')]
14912 }});
14913 ALL_CHARTS.push(covChart);
14914 }}
14915 var tierCanvas = document.getElementById('canvas-cov-tiers');
14916 if (tierCanvas && tiers) {{
14917 var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
14918 tierChart = new Chart(tierCanvas, {{
14919 type: 'doughnut',
14920 data: {{
14921 labels: ['High (\u226580%)', 'Moderate (50\u201379%)', 'Low (<50%)'],
14922 datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8', hoverOffset: 14 }}]
14923 }},
14924 options: {{
14925 responsive: true, maintainAspectRatio: false, cutout: '62%',
14926 onHover: tmFadeHover,
14927 plugins: {{
14928 legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 14 }},
14929 onHover: tmDoughnutLegendHover,
14930 onLeave: tmDoughnutLegendLeave
14931 }},
14932 tooltip: {{ callbacks: {{ label: function(ctx) {{
14933 var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
14934 return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
14935 }} }} }}
14936 }}
14937 }},
14938 plugins: [donutPctPlugin]
14939 }});
14940 ALL_CHARTS.push(tierChart);
14941 }}
14942 }}
14943
14944 function buildLangTable(D) {{
14945 var tbody = document.getElementById('lang-tbody');
14946 if (!tbody) return;
14947 if (!D || !D.length) {{
14948 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>';
14949 return;
14950 }}
14951 var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
14952 tbody.innerHTML = D.map(function(d) {{
14953 var barW = Math.round(d.density / maxDensity * 120);
14954 return '<tr>' +
14955 '<td><strong>' + d.lang + '</strong></td>' +
14956 '<td class="num">' + fmtFull(d.tests) + '</td>' +
14957 '<td class="num">' + fmtFull(d.assertions || 0) + '</td>' +
14958 '<td class="num">' + fmtFull(d.suites || 0) + '</td>' +
14959 '<td class="num">' + fmtFull(d.code) + '</td>' +
14960 '<td class="num">' + fmtFull(d.files) + '</td>' +
14961 '<td class="num">' + d.density.toFixed(2) + '</td>' +
14962 '<td><div class="density-bar-wrap"><div class="density-bar" style="width:' + barW + 'px;"></div></div></td>' +
14963 '</tr>';
14964 }}).join('');
14965 }}
14966
14967 var covFileData = [];
14968 var covFileTier = 'all';
14969 var covFileSearch = '';
14970
14971 function pctBadge(pct) {{
14972 var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
14973 var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
14974 return '<span class="cov-pct-badge" style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
14975 }}
14976
14977 function buildCovFileTable() {{
14978 var tbody = document.getElementById('cov-file-tbody');
14979 var empty = document.getElementById('cov-file-empty');
14980 var count = document.getElementById('cov-file-count');
14981 if (!tbody) return;
14982 var srch = covFileSearch.toLowerCase();
14983 var filtered = covFileData.filter(function(f) {{
14984 if (covFileTier === 'zero' && f.line_pct > 0) return false;
14985 if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
14986 if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
14987 if (covFileTier === 'high' && f.line_pct < 80) return false;
14988 if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
14989 return true;
14990 }});
14991 if (!filtered.length) {{
14992 tbody.innerHTML = '';
14993 if (empty) empty.style.display = '';
14994 if (count) count.textContent = '';
14995 return;
14996 }}
14997 if (empty) empty.style.display = 'none';
14998 var shown = Math.min(filtered.length, 500);
14999 if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
15000 tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
15001 var fnCol = f.fn_pct < 0
15002 ? '<td class="num" style="color:var(--muted);font-size:11px;">\u2014</td><td class="num" style="color:var(--muted);font-size:11px;">\u2014</td>'
15003 : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num" style="color:var(--muted);font-size:11px;">' + f.fhit + ' / ' + f.ffound + '</td>';
15004 return '<tr>' +
15005 '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '"') + '">' + f.rel + '</td>' +
15006 '<td style="color:var(--muted);font-size:11px;white-space:nowrap;">' + f.lang + '</td>' +
15007 '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
15008 '<td class="num" style="color:var(--muted);font-size:11px;">' + f.lhit + ' / ' + f.lfound + '</td>' +
15009 fnCol +
15010 '</tr>';
15011 }}).join('');
15012 }}
15013
15014 (function() {{
15015 var tabs = document.getElementById('cov-filter-tabs');
15016 if (tabs) {{
15017 tabs.addEventListener('click', function(e) {{
15018 var btn = e.target.closest('.cov-tab');
15019 if (!btn) return;
15020 Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
15021 btn.classList.add('active');
15022 covFileTier = btn.getAttribute('data-tier');
15023 buildCovFileTable();
15024 }});
15025 }}
15026 var srch = document.getElementById('cov-file-search');
15027 if (srch) {{
15028 srch.addEventListener('input', function() {{
15029 covFileSearch = this.value;
15030 buildCovFileTable();
15031 }});
15032 }}
15033 }})();
15034
15035 function updateCovGauges(t) {{
15036 var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
15037 var el;
15038 if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
15039 if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
15040 if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
15041 if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
15042 if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
15043 if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
15044 }}
15045
15046 function applyScope() {{
15047 var d = getDataset();
15048 var t = d.totals;
15049 var el;
15050 if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
15051 if ((el = document.getElementById('chip-total-exact'))) el.textContent = fmtFull(t.test_count);
15052 if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
15053 if ((el = document.getElementById('chip-assertions-exact'))) el.textContent = fmtFull(t.assertions);
15054 if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
15055 if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
15056 if ((el = document.getElementById('chip-test-files-exact'))) el.textContent = fmtFull(t.test_files) + ' / ' + fmtFull(t.total_files);
15057 if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
15058 if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
15059 if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
15060 if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
15061 renderTestCharts(d.lang_tests);
15062 renderAssertionsChart(d.lang_tests);
15063 renderSuitesChart(d.lang_tests);
15064 renderFilesChart(t);
15065 renderCompositionChart(t);
15066 buildLangTable(d.lang_tests);
15067 var covPanel = document.getElementById('cov-panel');
15068 if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
15069 if (d.has_coverage) {{
15070 renderCovCharts(d.cov, d.cov_tiers);
15071 updateCovGauges(t);
15072 covFileData = d.file_cov || [];
15073 covFileTier = 'all';
15074 covFileSearch = '';
15075 var tabs = document.getElementById('cov-filter-tabs');
15076 if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
15077 var srch = document.getElementById('cov-file-search');
15078 if (srch) srch.value = '';
15079 buildCovFileTable();
15080 }}
15081 loadTrend();
15082 }}
15083
15084 // Populate scope-root-sel from SCOPE_DATA keys
15085 (function() {{
15086 var sel = document.getElementById('scope-root-sel');
15087 if (!sel) return;
15088 Object.keys(SCOPE_DATA).forEach(function(k) {{
15089 if (k === '__all__') return;
15090 var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
15091 }});
15092 }})();
15093
15094 document.getElementById('scope-root-sel').addEventListener('change', function() {{
15095 currentRoot = this.value;
15096 currentSub = '';
15097 var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
15098 var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
15099 var subWrap = document.getElementById('scope-sub-wrap');
15100 var subSel = document.getElementById('scope-sub-sel');
15101 subSel.innerHTML = '<option value="">Entire project</option>';
15102 if (subNames.length) {{
15103 subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
15104 subWrap.style.display = 'flex';
15105 }} else {{
15106 subWrap.style.display = 'none';
15107 }}
15108 applyScope();
15109 }});
15110
15111 document.getElementById('scope-sub-sel').addEventListener('change', function() {{
15112 currentSub = this.value;
15113 applyScope();
15114 }});
15115
15116 var allTrendData = [];
15117
15118 var TM_Y_META = {{
15119 test_count: {{ label: 'Test Definitions', color: '#C45C10', tooltip: ' test defs' }},
15120 code_lines: {{ label: 'Code Lines', color: '#2A6846', tooltip: ' code lines' }}
15121 }};
15122
15123 // Parse a hex color (#RRGGBB) into "r,g,b" for building rgba() gradient stops.
15124 function hexRgb(hex) {{
15125 var h = String(hex).replace('#', '');
15126 if (h.length === 3) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2];
15127 var n = parseInt(h, 16);
15128 return ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255);
15129 }}
15130 // Vertical area-fill gradient matching the inline trend chart: fades from a soft
15131 // tint at the top to transparent at the bottom (no flat solid block).
15132 function tmTrendGradient(ctx2, chartArea, color) {{
15133 var rgb = hexRgb(color);
15134 var g = ctx2.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
15135 g.addColorStop(0, 'rgba(' + rgb + ',0.28)');
15136 g.addColorStop(0.5, 'rgba(' + rgb + ',0.10)');
15137 g.addColorStop(1, 'rgba(' + rgb + ',0)');
15138 return g;
15139 }}
15140
15141 // Pixel Y of the trend line at canvas-space x (tension 0 → straight segments,
15142 // so linear interpolation between adjacent points matches the drawn line).
15143 function tmLineYAt(chart, px) {{
15144 var meta = chart.getDatasetMeta(0);
15145 if (!meta || !meta.data || !meta.data.length) return null;
15146 var d = meta.data;
15147 if (px <= d[0].x) return d[0].y;
15148 for (var i = 1; i < d.length; i++) {{
15149 if (px <= d[i].x) {{
15150 var span = d[i].x - d[i - 1].x;
15151 var t = span > 0 ? (px - d[i - 1].x) / span : 0;
15152 return d[i - 1].y + t * (d[i].y - d[i - 1].y);
15153 }}
15154 }}
15155 return d[d.length - 1].y;
15156 }}
15157
15158 // Plugin: only show the tooltip / finger cursor when the pointer is over the
15159 // gradient fill (inside the plot and at/below the line) — never in the empty
15160 // space above the line. Outside the fill we retype the event as 'mouseout' so
15161 // the core interaction dismisses any active tooltip on its own.
15162 var tmFillGuard = {{
15163 id: 'tmFillGuard',
15164 beforeEvent: function(chart, args) {{
15165 var e = args.event;
15166 if (!e || e.type !== 'mousemove') return;
15167 var ca = chart.chartArea;
15168 if (!ca) return;
15169 var inFill = false;
15170 if (e.x >= ca.left && e.x <= ca.right) {{
15171 var ly = tmLineYAt(chart, e.x);
15172 if (ly != null && e.y >= ly - 6 && e.y <= ca.bottom) inFill = true;
15173 }}
15174 if (chart.canvas) chart.canvas.style.cursor = inFill ? 'pointer' : 'default';
15175 if (!inFill) {{ e.type = 'mouseout'; }}
15176 }}
15177 }};
15178
15179 // Single source of truth for the test-metrics trend chart config so the inline
15180 // chart and the Full View modal render identically (straight segments, gradient
15181 // fill, white-ringed points, gradient-only interactivity).
15182 function buildTmTrendConfig(pts, ctrl, meta) {{
15183 return {{
15184 type: 'line',
15185 data: {{
15186 labels: pts.map(function(d){{ return makeTrendLabel(d, ctrl.xMode); }}),
15187 datasets: [{{
15188 label: meta.label,
15189 data: pts.map(function(d){{ return Number(d[ctrl.yKey]) || 0; }}),
15190 borderColor: meta.color,
15191 borderWidth: 2.5,
15192 backgroundColor: function(context) {{
15193 var ca = context.chart.chartArea;
15194 if (!ca) return 'rgba(' + hexRgb(meta.color) + ',0.15)';
15195 return tmTrendGradient(context.chart.ctx, ca, meta.color);
15196 }},
15197 pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : meta.color; }}),
15198 pointBorderColor: '#fff',
15199 pointBorderWidth: 2,
15200 pointRadius: 6,
15201 pointHoverRadius: 9,
15202 pointHoverBorderWidth: 2.5,
15203 fill: true, tension: 0
15204 }}]
15205 }},
15206 options: {{
15207 responsive: true, maintainAspectRatio: false,
15208 layout: {{ padding: {{ top: 22 }} }},
15209 interaction: {{ mode: 'index', intersect: false }},
15210 plugins: {{
15211 legend: {{ display: false }},
15212 tooltip: {{
15213 mode: 'index', intersect: false,
15214 callbacks: {{ label: function(ctx2){{ return ' ' + fmtFull(ctx2.parsed.y) + meta.tooltip; }} }}
15215 }}
15216 }},
15217 scales: {{
15218 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, maxRotation:35 }} }},
15219 y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
15220 }}
15221 }},
15222 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top'), tmFillGuard]
15223 }};
15224 }}
15225
15226 function getTrendControls() {{
15227 var ySel = document.getElementById('tm-trend-y');
15228 var xSel = document.getElementById('tm-trend-x');
15229 var sizeSel = document.getElementById('tm-trend-size');
15230 var subSel = document.getElementById('tm-trend-sub');
15231 return {{
15232 yKey: ySel ? ySel.value : 'test_count',
15233 xMode: xSel ? xSel.value : 'commit',
15234 height: sizeSel ? parseInt(sizeSel.value, 10) : 260,
15235 submod: subSel ? subSel.value : ''
15236 }};
15237 }}
15238
15239 function makeTrendLabel(d, xMode) {{
15240 if (xMode === 'commit') {{
15241 return d.commit ? d.commit.substring(0, 7) : (d.run_id_short || '?');
15242 }}
15243 return d.timestamp ? d.timestamp.slice(0, 10) : d.run_id_short;
15244 }}
15245
15246 function buildTrend(data) {{
15247 allTrendData = data || [];
15248 renderTrend();
15249 }}
15250
15251 function renderTrend() {{
15252 var data = allTrendData;
15253 var ctrl = getTrendControls();
15254 var trendCanvas = document.getElementById('canvas-trend');
15255 var trendWrap = document.getElementById('trend-canvas-wrap');
15256 var trendEmpty = document.getElementById('trend-empty');
15257
15258 // Apply chart size
15259 if (trendWrap) trendWrap.style.height = ctrl.height + 'px';
15260
15261 // Filter by submodule if selected (entries from project_label match)
15262 var pts = data.slice().reverse();
15263 if (ctrl.submod) {{
15264 pts = pts.filter(function(d) {{ return d.project_label === ctrl.submod; }});
15265 }}
15266
15267 currentTrendPts = pts;
15268
15269 if (!pts.length) {{
15270 if (trendCanvas) trendCanvas.style.display = 'none';
15271 if (trendEmpty) trendEmpty.style.display = '';
15272 return;
15273 }}
15274 if (trendCanvas) trendCanvas.style.display = '';
15275 if (trendEmpty) trendEmpty.style.display = 'none';
15276
15277 trendChart = destroyChart(trendChart);
15278 if (!trendCanvas) return;
15279
15280 var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
15281
15282 trendChart = new Chart(trendCanvas, buildTmTrendConfig(pts, ctrl, meta));
15283 trendCanvas.addEventListener('mouseleave', function() {{ trendCanvas.style.cursor = 'default'; }});
15284 ALL_CHARTS.push(trendChart);
15285
15286 // Populate submodule selector from unique project_labels
15287 var subSel = document.getElementById('tm-trend-sub');
15288 var subLabel = document.getElementById('tm-sub-label');
15289 if (subSel && data.length) {{
15290 var projects = [];
15291 data.forEach(function(d) {{ if (d.project_label && projects.indexOf(d.project_label) < 0) projects.push(d.project_label); }});
15292 if (projects.length > 1) {{
15293 var curVal = subSel.value;
15294 subSel.innerHTML = '<option value="">All (project total)</option>';
15295 projects.forEach(function(p) {{ subSel.innerHTML += '<option value="'+p.replace(/"/g,'"')+'"'+(p===curVal?' selected':'')+'>'+p+'</option>'; }});
15296 if (subLabel) subLabel.style.display = '';
15297 }} else {{
15298 if (subLabel) subLabel.style.display = 'none';
15299 }}
15300 }}
15301 }}
15302
15303 // ── Full View expand buttons ──────────────────────────────────────────────
15304 (function() {{
15305 var btn = document.getElementById('tests-expand-btn');
15306 if (!btn) return;
15307 btn.addEventListener('click', function() {{
15308 var D = currentLangTests;
15309 if (!D || !D.length) return;
15310 var top15 = D.slice(0, 15);
15311 var h = Math.max(320, top15.length * 36 + 80);
15312 var canvas = makeTmOverlay('Test Definitions by Language \u2014 Full View', top15.length + ' languages', h);
15313 if (!canvas) return;
15314 new Chart(canvas, {{
15315 type: 'bar',
15316 data: {{
15317 labels: top15.map(function(d){{ return d.lang; }}),
15318 datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
15319 }},
15320 options: {{
15321 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15322 layout: {{ padding: {{ right: 72 }} }},
15323 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15324 scales: {{
15325 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
15326 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15327 }}
15328 }},
15329 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15330 }});
15331 }});
15332 }})();
15333
15334 (function() {{
15335 var btn = document.getElementById('density-expand-btn');
15336 if (!btn) return;
15337 btn.addEventListener('click', function() {{
15338 var D = currentLangTests;
15339 if (!D || !D.length) return;
15340 var topD = D.slice().sort(function(a,b){{ return b.density - a.density; }}).slice(0, 15);
15341 var h = Math.max(320, topD.length * 36 + 80);
15342 var canvas = makeTmOverlay('Test Density (per 1,000 code lines) \u2014 Full View', topD.length + ' languages', h);
15343 if (!canvas) return;
15344 new Chart(canvas, {{
15345 type: 'bar',
15346 data: {{
15347 labels: topD.map(function(d){{ return d.lang; }}),
15348 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 }}]
15349 }},
15350 options: {{
15351 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15352 layout: {{ padding: {{ right: 72 }} }},
15353 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
15354 scales: {{
15355 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return v.toFixed(1); }} }} }},
15356 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15357 }}
15358 }},
15359 plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
15360 }});
15361 }});
15362 }})();
15363
15364 (function() {{
15365 var btn = document.getElementById('trend-expand-btn');
15366 if (!btn) return;
15367 btn.addEventListener('click', function() {{
15368 var pts = currentTrendPts;
15369 if (!pts || !pts.length) return;
15370 var ctrl = getTrendControls();
15371 var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
15372 var title = meta.label + ' Trend \u2014 Full View';
15373 var canvas = makeTmOverlay(title, pts.length + ' scan' + (pts.length !== 1 ? 's' : ''), 440);
15374 if (!canvas) return;
15375 // Reuse the exact inline-chart config so Full View matches the default view
15376 // (straight segments + gradient-only interactivity), just larger.
15377 new Chart(canvas, buildTmTrendConfig(pts, ctrl, meta));
15378 }});
15379 }})();
15380
15381 (function() {{
15382 var btn = document.getElementById('assertions-expand-btn');
15383 if (!btn) return;
15384 btn.addEventListener('click', function() {{
15385 var D = currentLangTests;
15386 if (!D || !D.length) return;
15387 var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
15388 if (!top15.length) return;
15389 var h = Math.max(320, top15.length * 36 + 80);
15390 var canvas = makeTmOverlay('Assertions by Language \u2014 Full View', top15.length + ' languages', h);
15391 if (!canvas) return;
15392 new Chart(canvas, {{
15393 type: 'bar',
15394 data: {{
15395 labels: top15.map(function(d){{ return d.lang; }}),
15396 datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
15397 }},
15398 options: {{
15399 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15400 layout: {{ padding: {{ right: 72 }} }},
15401 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15402 scales: {{
15403 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
15404 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15405 }}
15406 }},
15407 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15408 }});
15409 }});
15410 }})();
15411
15412 (function() {{
15413 var btn = document.getElementById('suites-expand-btn');
15414 if (!btn) return;
15415 btn.addEventListener('click', function() {{
15416 var D = currentLangTests;
15417 if (!D || !D.length) return;
15418 var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
15419 if (!top15.length) return;
15420 var h = Math.max(320, top15.length * 36 + 80);
15421 var canvas = makeTmOverlay('Test Suites by Language \u2014 Full View', top15.length + ' languages', h);
15422 if (!canvas) return;
15423 new Chart(canvas, {{
15424 type: 'bar',
15425 data: {{
15426 labels: top15.map(function(d){{ return d.lang; }}),
15427 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 }}]
15428 }},
15429 options: {{
15430 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
15431 layout: {{ padding: {{ right: 72 }} }},
15432 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
15433 scales: {{
15434 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
15435 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
15436 }}
15437 }},
15438 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
15439 }});
15440 }});
15441 }})();
15442
15443 // Wire trend control selectors — re-render without re-fetching
15444 (function() {{
15445 ['tm-trend-y','tm-trend-x','tm-trend-size','tm-trend-sub'].forEach(function(id) {{
15446 var el = document.getElementById(id);
15447 if (el) el.addEventListener('change', function() {{ renderTrend(); }});
15448 }});
15449 }})();
15450
15451 function loadTrend() {{
15452 var url = '/api/metrics/history?limit=100';
15453 if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
15454 fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
15455 buildTrend(data);
15456 // Show Multi-Timeline button when >= 2 scans exist for the selected project.
15457 var btn = document.getElementById('multi-compare-trend-btn');
15458 if (btn) {{
15459 var ids = data.filter(function(d){{ return d.run_id; }}).map(function(d){{ return d.run_id; }});
15460 if (ids.length >= 2) {{
15461 btn.style.display = '';
15462 btn.onclick = function() {{
15463 // Reverse so oldest first (API returns newest first).
15464 var sorted = ids.slice().reverse();
15465 if (sorted.length === 2) {{
15466 window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
15467 }} else {{
15468 window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
15469 }}
15470 }};
15471 }} else {{
15472 btn.style.display = 'none';
15473 }}
15474 }}
15475 }}).catch(function(){{
15476 var trendEmpty = document.getElementById('trend-empty');
15477 if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
15478 }});
15479 }}
15480
15481 // Re-render charts on theme toggle
15482 document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
15483 setTimeout(function() {{
15484 ALL_CHARTS.forEach(function(c) {{
15485 if (c && c.options && c.options.scales) {{
15486 Object.values(c.options.scales).forEach(function(ax) {{
15487 if (ax.grid) ax.grid.color = clr();
15488 if (ax.ticks) ax.ticks.color = txtClr();
15489 }});
15490 c.update();
15491 }}
15492 }});
15493 }}, 80);
15494 }});
15495
15496 // ── Export helpers (Excel / PNG / PDF) ───────────────────────────────────
15497 var TM_FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
15498 function tmExportMeta() {{
15499 var sel = document.getElementById('scope-sel');
15500 var proj = sel && sel.options[sel.selectedIndex] ? sel.options[sel.selectedIndex].text : 'All projects';
15501 if (!proj || proj === '__all__') proj = 'All projects';
15502 var now = new Date(); function p2(n) {{ return (n<10?'0':'')+n; }}
15503 var dstr = now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate());
15504 var tstr = p2(now.getHours())+':'+p2(now.getMinutes());
15505 var slug = dstr+'_'+p2(now.getHours())+p2(now.getMinutes());
15506 return {{ proj: proj, date: dstr, time: tstr, slug: slug, full: dstr+' '+tstr }};
15507 }}
15508
15509 function exportTmXLSX() {{
15510 var D = currentLangTests;
15511 if (!D || !D.length) {{ alert('No test data to export yet.'); return; }}
15512 var t = tmExportMeta();
15513 function s2b(s) {{ return new TextEncoder().encode(s); }}
15514 function xe(s) {{ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }}
15515 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; }}
15516 function crc32(d) {{
15517 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;}}}}
15518 var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
15519 }}
15520 // Store all cells as strings so Excel left-aligns uniformly.
15521 function cs(addr, val, bold) {{
15522 return '<c r="'+addr+'" t="inlineStr"'+(bold?' s="1"':'')+"><is><t>"+xe(String(val))+'</t></is></c>';
15523 }}
15524 // Build an Excel Table XML definition for a given sheet range and columns.
15525 function makeTableXml(tblId, name, ref, cols) {{
15526 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
15527 x+='<table xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
15528 x+=' id="'+tblId+'" name="'+name+'" displayName="'+name+'" ref="'+ref+'" headerRowCount="1">';
15529 x+='<autoFilter ref="'+ref+'"/>';
15530 x+='<tableColumns count="'+cols.length+'">';
15531 cols.forEach(function(col,i){{x+='<tableColumn id="'+(i+1)+'" name="'+xe(col)+'"/>';}});
15532 x+='</tableColumns>';
15533 x+='<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>';
15534 return x+'</table>';
15535 }}
15536 // Worksheet XML with optional Excel Table part reference.
15537 function buildSheet(hdr, rows, totRow, colWidths, tblRid) {{
15538 var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
15539 if(tblRid)ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';
15540 var cw='<cols>';colWidths.forEach(function(w,i){{cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';}});cw+='</cols>';
15541 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>'+cw+'<sheetData>';
15542 x+='<row r="1">';hdr.forEach(function(h,ci){{x+=cs(col2l(ci+1)+'1',h,true);}});x+='</row>';
15543 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>';}});
15544 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>';}}
15545 x+='</sheetData>';
15546 if(tblRid)x+='<tableParts count="1"><tablePart r:id="'+tblRid+'"/></tableParts>';
15547 return x+'</worksheet>';
15548 }}
15549
15550 var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
15551 var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
15552 var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
15553 var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
15554 var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
15555 var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
15556
15557 // ── Build the worksheet list (test metrics + optional LCOV coverage) ──
15558 // Each entry: {{name, tbl (Excel table name), hdr, rows, tot, cols}}.
15559 var sheets=[];
15560
15561 // Sheet: Summary
15562 var sumHdr=['Metric','Value'];
15563 var sumRows=[
15564 ['Project / Scope', t.proj],
15565 ['Export Date', t.full],
15566 ['Test Functions', Number(totTests).toLocaleString()],
15567 ['Assertions', Number(totAssert).toLocaleString()],
15568 ['Test Suites', Number(totSuites).toLocaleString()],
15569 ['Languages with Tests', String(D.length)],
15570 ['Total Code Lines', Number(totCode).toLocaleString()],
15571 ['Average Density (per 1K)', String(avgDensity)],
15572 ];
15573 sheets.push({{name:'Summary',tbl:'Summary',hdr:sumHdr,rows:sumRows,tot:null,cols:[28,22]}});
15574
15575 // Sheet: Language Breakdown (TOTAL row sits just below the table range)
15576 var langHdr=['Language','Test Functions','Assertions','Test Suites','Code Lines','Files','Density (per 1K)'];
15577 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)];}});
15578 var totRow=['TOTAL',Number(totTests).toLocaleString(),Number(totAssert).toLocaleString(),Number(totSuites).toLocaleString(),Number(totCode).toLocaleString(),Number(totFiles).toLocaleString(),String(avgDensity)];
15579 sheets.push({{name:'Language Breakdown',tbl:'LangBreakdown',hdr:langHdr,rows:langRows,tot:totRow,cols:[22,15,15,15,15,12,15]}});
15580
15581 // Sheets: LCOV Coverage Summary (appended only when the current scope has coverage)
15582 var covDs=(typeof getDataset==='function')?getDataset():null;
15583 if(covDs&&covDs.has_coverage){{
15584 var covT=covDs.totals||{{}};
15585 var covSumHdr=['Metric','Value'];
15586 var covSumRows=[
15587 ['Line Coverage', (covT.cov_line||'0')+'%'],
15588 ['Function Coverage', (covT.cov_fn||'0')+'%'],
15589 ['Branch Coverage', (covT.cov_branch||'0')+'%'],
15590 ];
15591 if(covDs.cov_tiers){{
15592 covSumRows.push(['Files High (≥80%)', String(covDs.cov_tiers.high||0)]);
15593 covSumRows.push(['Files Moderate (50-79%)', String(covDs.cov_tiers.mid||0)]);
15594 covSumRows.push(['Files Low (<50%)', String(covDs.cov_tiers.low||0)]);
15595 }}
15596 sheets.push({{name:'Coverage Summary',tbl:'CoverageSummary',hdr:covSumHdr,rows:covSumRows,tot:null,cols:[26,14]}});
15597
15598 if(covDs.cov&&covDs.cov.length){{
15599 var covLangHdr=['Language','Line Coverage %'];
15600 var covLangRows=covDs.cov.map(function(c){{return[c.lang,Number(c.pct).toFixed(1)];}});
15601 sheets.push({{name:'Coverage by Language',tbl:'CoverageByLang',hdr:covLangHdr,rows:covLangRows,tot:null,cols:[24,18]}});
15602 }}
15603 if(covFileData&&covFileData.length){{
15604 var covFileHdr=['File','Language','Line %','Lines Hit','Lines Found','Function %','Fns Hit','Fns Found'];
15605 var covFileRows=covFileData.map(function(f){{
15606 var noFn=f.fn_pct<0;
15607 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)];
15608 }});
15609 sheets.push({{name:'Coverage by File',tbl:'CoverageByFile',hdr:covFileHdr,rows:covFileRows,tot:null,cols:[40,14,10,10,12,12,10,10]}});
15610 }}
15611 }}
15612
15613 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>';
15614 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>';
15615
15616 // Assemble per-sheet parts, content-type overrides, and workbook relationships.
15617 var files=[];
15618 var ctOverrides='', wbSheetTags='', wbRelTags='';
15619 sheets.forEach(function(sh,i){{
15620 var n=i+1;
15621 var lastCol=col2l(sh.hdr.length);
15622 var ref='A1:'+lastCol+(sh.rows.length+1);
15623 var sheetXml=buildSheet(sh.hdr,sh.rows,sh.tot,sh.cols,'rId1');
15624 var tblXml=makeTableXml(n,sh.tbl,ref,sh.hdr);
15625 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>';
15626 files.push({{name:'xl/worksheets/sheet'+n+'.xml',data:s2b(sheetXml)}});
15627 files.push({{name:'xl/worksheets/_rels/sheet'+n+'.xml.rels',data:s2b(shRels)}});
15628 files.push({{name:'xl/tables/table'+n+'.xml',data:s2b(tblXml)}});
15629 ctOverrides+='<Override PartName="/xl/worksheets/sheet'+n+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';
15630 ctOverrides+='<Override PartName="/xl/tables/table'+n+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';
15631 wbSheetTags+='<sheet name="'+xe(sh.name)+'" sheetId="'+n+'" r:id="rId'+n+'"/>';
15632 wbRelTags+='<Relationship Id="rId'+n+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet'+n+'.xml"/>';
15633 }});
15634 var styleRid='rId'+(sheets.length+1);
15635 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>';
15636 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>';
15637 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>';
15638 files.unshift(
15639 {{name:'[Content_Types].xml',data:s2b(ct)}},
15640 {{name:'_rels/.rels',data:s2b(dotrels)}},
15641 {{name:'xl/workbook.xml',data:s2b(wbx)}},
15642 {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
15643 {{name:'xl/styles.xml',data:s2b(styl)}}
15644 );
15645 var parts=[],offsets=[],total=0;
15646 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;}});
15647 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;}});
15648 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));
15649 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;}});
15650 var proj2=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15651 var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj2+'-'+t.slug+'.xlsx';
15652 a.href=URL.createObjectURL(new Blob([out.buffer],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
15653 a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
15654 }}
15655
15656 function exportTmPNG() {{
15657 // Map canvas IDs to display titles
15658 var CHART_TITLES = {{
15659 'canvas-trend': 'TEST COUNT TREND',
15660 'canvas-tests': 'TEST DEFINITIONS BY LANGUAGE',
15661 'canvas-density': 'TEST DENSITY (per 1,000 code lines)',
15662 'canvas-assertions': 'ASSERTIONS BY LANGUAGE',
15663 'canvas-suites': 'TEST SUITES BY LANGUAGE',
15664 'canvas-files': 'TEST FILES BREAKDOWN',
15665 'canvas-composition': 'TEST COMPOSITION',
15666 'canvas-cov': 'LINE COVERAGE % BY LANGUAGE',
15667 'canvas-cov-tiers': 'COVERAGE TIER DISTRIBUTION'
15668 }};
15669 // Coverage canvases are only appended when the LCOV panel is visible (has data).
15670 var covPanelEl=document.getElementById('cov-panel');
15671 var covShown=covPanelEl&&covPanelEl.style.display!=='none';
15672 var ids=['canvas-trend','canvas-tests','canvas-density','canvas-assertions','canvas-suites','canvas-files','canvas-composition'];
15673 if(covShown){{ids.push('canvas-cov','canvas-cov-tiers');}}
15674 // Include only charts that actually rendered data. A "no data" chart has its
15675 // canvas wrap hidden (offsetParent===null) with a placeholder shown instead —
15676 // skip those so the image has no empty gaps (e.g. Assertions/Suites at 0).
15677 function chartHasData(c){{return c&&c.width>0&&c.offsetParent!==null;}}
15678 var canvases=ids.map(function(id){{return document.getElementById(id);}}).filter(chartHasData);
15679 if(!canvases.length){{alert('No charts rendered yet. Run a scan first.');return;}}
15680 var t=tmExportMeta();
15681 var COLW=760, GAP=16, HEADER_H=102, FOOTER_H=40, ROW_PAD=18, TITLE_H=26;
15682 var trendCanvas=document.getElementById('canvas-trend');
15683 var hasTrend=chartHasData(trendCanvas);
15684 var gridCanvases=canvases.filter(function(c){{return c.id!=='canvas-trend';}});
15685 var TOTAL_W=COLW*2+GAP;
15686 var TREND_H=hasTrend?Math.round(TOTAL_W*(trendCanvas.height/Math.max(trendCanvas.width,1))):0;
15687 TREND_H=Math.min(Math.max(200,TREND_H),340);
15688 // Per-row chart heights (2-col grid)
15689 var gridRows=Math.ceil(gridCanvases.length/2);
15690 var rowHeights=[];
15691 for(var ri=0;ri<gridRows;ri++){{
15692 var rh=240;
15693 for(var ci=0;ci<2;ci++){{
15694 var cv=gridCanvases[ri*2+ci];
15695 if(cv&&cv.width>0){{
15696 var nat=Math.round(COLW*cv.height/Math.max(cv.width,1));
15697 rh=Math.max(rh,Math.min(420,nat));
15698 }}
15699 }}
15700 rowHeights.push(rh);
15701 }}
15702 var gridH=rowHeights.reduce(function(a,b){{return a+TITLE_H+b+ROW_PAD;}},0);
15703 var trendSection=hasTrend?TITLE_H+TREND_H+ROW_PAD:0;
15704 var TOTAL_H=HEADER_H+trendSection+gridH+FOOTER_H;
15705 var out=document.createElement('canvas');out.width=TOTAL_W;out.height=TOTAL_H;
15706 var ctx=out.getContext('2d');
15707 var cs2=getComputedStyle(document.body);
15708 var bg=cs2.getPropertyValue('--bg').trim()||'#f5efe8';
15709 var oxide=cs2.getPropertyValue('--oxide').trim()||'#C45C10';
15710 var muted=cs2.getPropertyValue('--muted').trim()||'#7b675b';
15711
15712 // Background
15713 ctx.fillStyle=bg;ctx.fillRect(0,0,TOTAL_W,TOTAL_H);
15714
15715 // Orange header block
15716 ctx.fillStyle=oxide;ctx.fillRect(0,0,TOTAL_W,HEADER_H-8);
15717 ctx.fillStyle='#fff';ctx.font='800 24px '+TM_FONT;ctx.textBaseline='alphabetic';ctx.textAlign='left';
15718 ctx.fillText('Test Metrics — '+t.proj,22,42);
15719 ctx.fillStyle='rgba(255,255,255,0.82)';ctx.font='600 13px '+TM_FONT;
15720 ctx.fillText('oxide-sloc v{version} · Generated '+t.full,22,70);
15721 ctx.fillStyle=bg;ctx.fillRect(0,HEADER_H-8,TOTAL_W,TOTAL_H-(HEADER_H-8));
15722
15723 // Helper: draw a section title label
15724 function drawTitle(label, x, y, w) {{
15725 ctx.save();
15726 ctx.fillStyle=oxide;
15727 ctx.font='700 11px '+TM_FONT;
15728 ctx.textBaseline='middle';
15729 ctx.textAlign='left';
15730 ctx.letterSpacing='0.07em';
15731 ctx.fillText(label, x+2, y+TITLE_H/2);
15732 // Underline
15733 ctx.strokeStyle=oxide;ctx.globalAlpha=0.35;ctx.lineWidth=1;
15734 ctx.beginPath();ctx.moveTo(x,y+TITLE_H-2);ctx.lineTo(x+w,y+TITLE_H-2);ctx.stroke();
15735 ctx.globalAlpha=1;
15736 ctx.restore();
15737 }}
15738
15739 var yOff=HEADER_H;
15740
15741 // Trend chart (full width)
15742 if(hasTrend){{
15743 drawTitle(CHART_TITLES['canvas-trend']||'TEST COUNT TREND', 4, yOff, TOTAL_W-8);
15744 yOff+=TITLE_H;
15745 var surf=document.createElement('canvas');surf.width=TOTAL_W;surf.height=TREND_H;
15746 var sc=surf.getContext('2d');sc.fillStyle=bg;sc.fillRect(0,0,TOTAL_W,TREND_H);
15747 sc.drawImage(trendCanvas,0,0,TOTAL_W,TREND_H);
15748 ctx.drawImage(surf,0,yOff);
15749 yOff+=TREND_H+ROW_PAD;
15750 }}
15751
15752 // Grid charts (2-col), each cell gets title + chart
15753 for(var gi=0;gi<gridRows;gi++){{
15754 var rh2=rowHeights[gi];
15755 // Draw row titles and charts
15756 for(var gci=0;gci<2;gci++){{
15757 var idx2=gi*2+gci;
15758 if(idx2>=gridCanvases.length)continue;
15759 var gcv=gridCanvases[idx2];
15760 var gx=gci*(COLW+GAP);
15761 drawTitle(CHART_TITLES[gcv.id]||gcv.id.replace('canvas-','').toUpperCase(), gx+4, yOff, COLW-8);
15762 }}
15763 yOff+=TITLE_H;
15764 for(var gci2=0;gci2<2;gci2++){{
15765 var idx3=gi*2+gci2;
15766 if(idx3>=gridCanvases.length)continue;
15767 var gcv2=gridCanvases[idx3];
15768 var gx2=gci2*(COLW+GAP);
15769 var natW=gcv2.width,natH=gcv2.height;
15770 var scale=Math.min(COLW/Math.max(natW,1),rh2/Math.max(natH,1));
15771 var dw=Math.round(natW*scale),dh=Math.round(natH*scale);
15772 var surf2=document.createElement('canvas');surf2.width=COLW;surf2.height=rh2;
15773 var sc2=surf2.getContext('2d');sc2.fillStyle=bg;sc2.fillRect(0,0,COLW,rh2);
15774 sc2.drawImage(gcv2,Math.round((COLW-dw)/2),Math.round((rh2-dh)/2),dw,dh);
15775 ctx.drawImage(surf2,gx2,yOff);
15776 }}
15777 yOff+=rh2+ROW_PAD;
15778 }}
15779
15780 // Dark footer
15781 ctx.fillStyle='#43342d';ctx.fillRect(0,TOTAL_H-FOOTER_H,TOTAL_W,FOOTER_H);
15782 ctx.fillStyle='rgba(255,255,255,0.72)';ctx.font='600 11px '+TM_FONT;ctx.textAlign='center';
15783 ctx.fillText('© 2026 OxideSLOC · oxide-sloc v{version} · AGPL-3.0-or-later',TOTAL_W/2,TOTAL_H-FOOTER_H+24);
15784
15785 var proj3=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15786 var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj3+'-'+t.slug+'.png';a.href=out.toDataURL('image/png');a.click();
15787 }}
15788
15789 function exportTmPDF(ev) {{
15790 var D=currentLangTests;
15791 var t=tmExportMeta();
15792 var strips=document.querySelectorAll('.summary-strip');
15793 var statsHtml='';strips.forEach(function(s){{statsHtml+=s.outerHTML;}});
15794 var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
15795 var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
15796 var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
15797 var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
15798 var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
15799 var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
15800 var rows='';
15801 (D||[]).forEach(function(d){{
15802 rows+='<tr><td><strong>'+d.lang+'</strong></td>'
15803 +'<td class="n">'+Number(d.tests).toLocaleString()+'</td>'
15804 +'<td class="n">'+Number(d.assertions||0).toLocaleString()+'</td>'
15805 +'<td class="n">'+Number(d.suites||0).toLocaleString()+'</td>'
15806 +'<td class="n">'+Number(d.code).toLocaleString()+'</td>'
15807 +'<td class="n">'+Number(d.files).toLocaleString()+'</td>'
15808 +'<td class="n">'+Number(d.density).toFixed(2)+'</td></tr>';
15809 }});
15810 var totRow='<tr class="tot-row"><td><strong>TOTAL</strong></td>'
15811 +'<td class="n"><strong>'+Number(totTests).toLocaleString()+'</strong></td>'
15812 +'<td class="n"><strong>'+Number(totAssert).toLocaleString()+'</strong></td>'
15813 +'<td class="n"><strong>'+Number(totSuites).toLocaleString()+'</strong></td>'
15814 +'<td class="n"><strong>'+Number(totCode).toLocaleString()+'</strong></td>'
15815 +'<td class="n"><strong>'+Number(totFiles).toLocaleString()+'</strong></td>'
15816 +'<td class="n"><strong>'+avgDensity+'</strong></td></tr>';
15817 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>';
15818 var css='<style>*{{box-sizing:border-box;margin:0;padding:0;}}'
15819 +'html,body{{height:100%;margin:0;}}'
15820 +'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;}}'
15821 +'.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;}}'
15822 +'.rep-header h1{{font-size:22px;font-weight:900;margin:0;color:#fff;}}'
15823 +'.rep-header .sub{{font-size:12px;margin:5px 0 0;color:rgba(255,255,255,0.85);}}'
15824 +'.rep-brand{{font-size:14px;font-weight:800;color:#fff;text-align:right;}}'
15825 +'.rep-brand small{{display:block;font-weight:500;font-size:11px;opacity:.85;margin-top:2px;}}'
15826 +'.rep-body{{padding:20px 32px;flex:1;}}'
15827 +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 12px;}}'
15828 +'.stat-chip{{border:1px solid #e6d0bf;border-radius:10px;padding:10px 12px;position:relative;}}'
15829 +'.stat-chip-tip,.stat-chip-exact{{display:none!important;}}'
15830 +'.stat-chip-val{{font-size:17px;font-weight:900;color:#C45C10;}}'
15831 +'.stat-chip-label{{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#7b675b;margin-top:3px;}}'
15832 +'.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;}}'
15833 +'table{{border-collapse:collapse;width:100%;font-size:11px;margin-top:4px;}}'
15834 +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;white-space:nowrap;}}'
15835 +'th{{background:#f5efe8;font-weight:800;font-size:10px;}}'
15836 +'.n{{text-align:right;}}'
15837 +'.tot-row td{{background:#f0e6dc;border-top:2px solid #C45C10;}}'
15838 +'.cov-strip{{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:4px 0 8px;}}'
15839 +'.cov-card{{border:1px solid #e6d0bf;border-radius:10px;padding:10px 12px;}}'
15840 +'.cov-k{{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#7b675b;}}'
15841 +'.cov-v{{font-size:18px;font-weight:900;color:#2a6846;margin-top:3px;}}'
15842 +'.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;}}'
15843 +'</style>';
15844 // LCOV Coverage Summary section — only rendered when the current scope has coverage.
15845 var covDs=(typeof getDataset==='function')?getDataset():null;
15846 var covHtml='';
15847 if(covDs&&covDs.has_coverage){{
15848 var covT=covDs.totals||{{}};
15849 covHtml+='<div class="section-hdr">LCOV Coverage Summary</div>'
15850 +'<div class="cov-strip">'
15851 +'<div class="cov-card"><div class="cov-k">Line Coverage</div><div class="cov-v">'+(covT.cov_line||'0')+'%</div></div>'
15852 +'<div class="cov-card"><div class="cov-k">Function Coverage</div><div class="cov-v">'+(covT.cov_fn||'0')+'%</div></div>'
15853 +'<div class="cov-card"><div class="cov-k">Branch Coverage</div><div class="cov-v">'+(covT.cov_branch||'0')+'%</div></div>'
15854 +'</div>';
15855 if(covFileData&&covFileData.length){{
15856 var cfrows='';
15857 covFileData.forEach(function(f){{
15858 var noFn=f.fn_pct<0;
15859 cfrows+='<tr><td>'+f.rel+'</td><td>'+f.lang+'</td>'
15860 +'<td class="n">'+Number(f.line_pct).toFixed(1)+'%</td>'
15861 +'<td class="n">'+f.lhit+' / '+f.lfound+'</td>'
15862 +'<td class="n">'+(noFn?'—':Number(f.fn_pct).toFixed(1)+'%')+'</td>'
15863 +'<td class="n">'+(noFn?'—':f.fhit+' / '+f.ffound)+'</td></tr>';
15864 }});
15865 covHtml+='<div class="section-hdr">Coverage File Detail</div>'
15866 +'<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>';
15867 }}
15868 }}
15869 var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Test Metrics</title>'+css+'</head><body>'
15870 +'<div class="rep-header"><div><h1>Test Metrics Report</h1><p class="sub">Scope: '+t.proj+' · Generated: '+t.full+'</p></div>'
15871 +'<div class="rep-brand">OxideSLOC<small>oxide-sloc v{version}</small></div></div>'
15872 +'<div class="rep-body">'+statsHtml
15873 +'<div class="section-hdr">Language Breakdown</div>'
15874 +tableHtml+covHtml+'</div>'
15875 +'<div class="rep-footer">© 2026 OxideSLOC · oxide-sloc v{version} · local code metrics workbench · AGPL-3.0-or-later · Generated '+t.full+'</div>'
15876 +'</body></html>';
15877 var proj4=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
15878 var pdfBtn=(ev&&ev.currentTarget)||document.getElementById('tm-export-pdf-btn');
15879 window.slocExportPdf({{html:doc,filename:'oxide-sloc-test-metrics-'+proj4+'-'+t.slug+'.pdf',button:pdfBtn}});
15880 }}
15881
15882 (function() {{
15883 // Page-level export controls (Scope toolbar). Every button exports the ENTIRE
15884 // Test Metrics page — test metrics + the LCOV Coverage Summary — for the scope.
15885 var xBtn=document.getElementById('tm-export-xlsx-btn');
15886 var pngBtn=document.getElementById('tm-export-png-btn');
15887 var pdfBtn=document.getElementById('tm-export-pdf-btn');
15888 if(xBtn)xBtn.addEventListener('click',exportTmXLSX);
15889 if(pngBtn)pngBtn.addEventListener('click',exportTmPNG);
15890 if(pdfBtn)pdfBtn.addEventListener('click',exportTmPDF);
15891 }})();
15892
15893 applyScope();
15894 }})();
15895 </script>
15896 <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>
15897 {toast_assets}
15898</body>
15899</html>"#,
15900 );
15901 (
15902 [(axum::http::header::CACHE_CONTROL, "no-store")],
15903 Html(html),
15904 )
15905 .into_response()
15906}
15907
15908#[derive(Deserialize)]
15915struct EmbedQuery {
15916 run_id: Option<String>,
15917 theme: Option<String>,
15918}
15919
15920async fn embed_handler(
15921 State(state): State<AppState>,
15922 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
15923 Query(query): Query<EmbedQuery>,
15924) -> Response {
15925 let entry = {
15926 let reg = state.registry.lock().await;
15927 query.run_id.as_ref().map_or_else(
15928 || reg.entries.first().cloned(),
15929 |id| reg.find_by_run_id(id).cloned(),
15930 )
15931 };
15932
15933 let Some(entry) = entry else {
15934 return Html(
15935 "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
15936 .to_string(),
15937 )
15938 .into_response();
15939 };
15940
15941 let dark = query.theme.as_deref() == Some("dark");
15942 let languages: Vec<(String, u64, u64)> = entry
15943 .json_path
15944 .as_ref()
15945 .and_then(|p| read_json(p).ok())
15946 .map(|run| {
15947 run.totals_by_language
15948 .iter()
15949 .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
15950 .collect()
15951 })
15952 .unwrap_or_default();
15953
15954 Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
15955}
15956
15957fn render_embed_widget(
15958 entry: &RegistryEntry,
15959 languages: &[(String, u64, u64)],
15960 dark: bool,
15961 csp_nonce: &str,
15962) -> String {
15963 let s = &entry.summary;
15964 let total = s.code_lines + s.comment_lines + s.blank_lines;
15965 let code_pct = s
15966 .code_lines
15967 .checked_mul(100)
15968 .and_then(|n| n.checked_div(total))
15969 .unwrap_or(0);
15970
15971 let (bg, fg, surface, muted, border) = if dark {
15972 ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
15973 } else {
15974 ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
15975 };
15976
15977 let mut lang_rows = String::new();
15978 for (name, files, code) in languages {
15979 write!(
15980 lang_rows,
15981 "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
15982 escape_html(name),
15983 format_number(*files),
15984 format_number(*code),
15985 )
15986 .ok();
15987 }
15988
15989 let lang_table = if lang_rows.is_empty() {
15990 String::new()
15991 } else {
15992 format!(
15993 "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
15994 )
15995 };
15996
15997 let run_short = &entry.run_id[..entry.run_id.len().min(8)];
15998 let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
15999 let project_esc = escape_html(&entry.project_label);
16000 let code_lines = format_number(s.code_lines);
16001 let comment_lines = format_number(s.comment_lines);
16002 let files = format_number(s.files_analyzed);
16003 let code_raw = s.code_lines;
16004 let comment_raw = s.comment_lines;
16005 let blank_raw = s.blank_lines;
16006
16007 format!(
16008 r#"<!doctype html>
16009<html lang="en">
16010<head>
16011 <meta charset="utf-8">
16012 <meta name="viewport" content="width=device-width,initial-scale=1">
16013 <title>OxideSLOC — {project_esc}</title>
16014 <script src="/static/chart.js"></script>
16015 <style nonce="{csp_nonce}">
16016 *{{box-sizing:border-box;margin:0;padding:0}}
16017 body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
16018 h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
16019 .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
16020 .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
16021 .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
16022 .card .v{{font-size:18px;font-weight:700}}
16023 .card .l{{color:{muted};font-size:10px;margin-top:2px}}
16024 .row{{display:flex;gap:12px;align-items:flex-start}}
16025 .pie{{width:120px;height:120px;flex-shrink:0}}
16026 .lt{{border-collapse:collapse;width:100%;flex:1}}
16027 .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
16028 .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
16029 .n{{text-align:right}}
16030 .footer{{margin-top:10px;color:{muted};font-size:10px}}
16031 </style>
16032</head>
16033<body>
16034 <h2>{project_esc}</h2>
16035 <div class="sub">{timestamp} · run {run_short}</div>
16036 <div class="cards">
16037 <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
16038 <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
16039 <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
16040 <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
16041 </div>
16042 <div class="row">
16043 <canvas class="pie" id="c"></canvas>
16044 {lang_table}
16045 </div>
16046 <div class="footer">oxide-sloc</div>
16047 <script nonce="{csp_nonce}">
16048 new Chart(document.getElementById('c'),{{
16049 type:'doughnut',
16050 data:{{
16051 labels:['Code','Comments','Blank'],
16052 datasets:[{{
16053 data:[{code_raw},{comment_raw},{blank_raw}],
16054 backgroundColor:['#4a78ee','#b35428','#aaa'],
16055 borderWidth:0
16056 }}]
16057 }},
16058 options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
16059 }});
16060 </script>
16061</body>
16062</html>"#
16063 )
16064}
16065
16066fn output_dir_lock(dir: &Path) -> Arc<std::sync::Mutex<()>> {
16071 static LOCKS: OnceLock<std::sync::Mutex<HashMap<PathBuf, Arc<std::sync::Mutex<()>>>>> =
16072 OnceLock::new();
16073 let map = LOCKS.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
16074 let mut guard = map
16075 .lock()
16076 .unwrap_or_else(std::sync::PoisonError::into_inner);
16077 guard
16078 .entry(dir.to_path_buf())
16079 .or_insert_with(|| Arc::new(std::sync::Mutex::new(())))
16080 .clone()
16081}
16082
16083#[allow(clippy::too_many_lines)]
16084fn persist_run_artifacts(
16085 run: &sloc_core::AnalysisRun,
16086 report_html: &str,
16087 run_dir: &Path,
16088 report_title: &str,
16089 file_stem: &str,
16090 result_context: RunResultContext,
16091) -> Result<(RunArtifacts, PendingPdf)> {
16092 let dir_lock = output_dir_lock(run_dir);
16095 let _dir_guard = dir_lock
16096 .lock()
16097 .unwrap_or_else(std::sync::PoisonError::into_inner);
16098
16099 let html_dir = run_dir.join("html");
16101 let pdf_dir = run_dir.join("pdf");
16102 let excel_dir = run_dir.join("excel");
16103 let json_dir = run_dir.join("json");
16104 let submodules_dir = run_dir.join("submodules");
16105 for dir in &[
16106 run_dir,
16107 &html_dir,
16108 &pdf_dir,
16109 &excel_dir,
16110 &json_dir,
16111 &submodules_dir,
16112 ] {
16113 fs::create_dir_all(dir)
16114 .with_context(|| format!("failed to create directory {}", dir.display()))?;
16115 }
16116
16117 let html_path = {
16119 let path = html_dir.join(format!("report_{file_stem}.html"));
16120 fs::write(&path, report_html)
16121 .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
16122 Some(path)
16123 };
16124
16125 let json_path = {
16127 let path = json_dir.join(format!("result_{file_stem}.json"));
16128 let json = serde_json::to_string_pretty(run)
16129 .context("failed to serialize analysis run to JSON")?;
16130 fs::write(&path, json)
16131 .with_context(|| format!("failed to write JSON result to {}", path.display()))?;
16132 Some(path)
16133 };
16134
16135 let (pdf_path, pending_pdf) = {
16137 let pdf_dest = pdf_dir.join(format!("report_{file_stem}.pdf"));
16138 match write_pdf_from_run(run, &pdf_dest) {
16139 Ok(()) => {
16140 eprintln!(
16141 "[oxide-sloc][pdf] native PDF written to {}",
16142 pdf_dest.display()
16143 );
16144 (Some(pdf_dest), None)
16145 }
16146 Err(native_err) => {
16147 eprintln!(
16148 "[oxide-sloc][pdf] native PDF failed ({native_err:#}), scheduling HTML->browser fallback"
16149 );
16150 let source_html_path = html_path
16151 .as_ref()
16152 .expect("html_path always Some here")
16153 .clone();
16154 let pending = Some((source_html_path, pdf_dest.clone(), false));
16155 (Some(pdf_dest), pending)
16156 }
16157 }
16158 };
16159
16160 let csv_path = {
16162 let path = excel_dir.join(format!("report_{file_stem}.csv"));
16163 match sloc_report::write_csv(run, &path) {
16164 Err(e) => {
16165 eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
16166 None
16167 }
16168 _ => Some(path),
16169 }
16170 };
16171
16172 let xlsx_path = {
16173 let path = excel_dir.join(format!("report_{file_stem}.xlsx"));
16174 match sloc_report::write_xlsx(run, &path) {
16175 Err(e) => {
16176 eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
16177 None
16178 }
16179 _ => Some(path),
16180 }
16181 };
16182
16183 let scan_config_path = Some(json_dir.join(format!("scan-config_{file_stem}.json")));
16185
16186 if run.effective_configuration.discovery.submodule_breakdown {
16188 let run_id = &run.tool.run_id;
16189 for s in &run.submodule_summaries {
16190 build_submodule_row(s, run, run_id, run_dir);
16191 }
16192 }
16193
16194 generate_offline_index(
16196 run,
16197 run_dir,
16198 file_stem,
16199 html_path.as_deref(),
16200 pdf_path.as_deref(),
16201 json_path.as_deref(),
16202 scan_config_path.as_deref(),
16203 &result_context,
16204 );
16205
16206 Ok((
16207 RunArtifacts {
16208 output_dir: run_dir.to_path_buf(),
16209 html_path,
16210 pdf_path,
16211 json_path,
16212 csv_path,
16213 xlsx_path,
16214 scan_config_path,
16215 report_title: report_title.to_string(),
16216 result_context,
16217 },
16218 pending_pdf,
16219 ))
16220}
16221
16222#[allow(clippy::too_many_arguments)]
16225#[allow(clippy::too_many_lines)]
16226#[allow(clippy::similar_names)]
16227fn generate_offline_index(
16228 run: &sloc_core::AnalysisRun,
16229 run_dir: &Path,
16230 file_stem: &str,
16231 html_path: Option<&Path>,
16232 pdf_path: Option<&Path>,
16233 json_path: Option<&Path>,
16234 scan_config_path: Option<&Path>,
16235 result_context: &RunResultContext,
16236) {
16237 let prev_entry = &result_context.prev_entry;
16238 let prev_scan_count = result_context.prev_scan_count;
16239 let project_path = &result_context.project_path;
16240
16241 let scan_delta = prev_entry.as_ref().and_then(|prev| {
16242 prev.json_path
16243 .as_ref()
16244 .and_then(|p| read_json(p).ok())
16245 .map(|prev_run| compute_delta(&prev_run, run))
16246 });
16247
16248 let files_analyzed = run.per_file_records.len() as u64;
16249 let files_skipped = run.skipped_file_records.len() as u64;
16250 let totals = sum_lang_totals(run);
16251
16252 let DeltaFields {
16253 prev_fa_str,
16254 prev_fs_str,
16255 prev_pl_str,
16256 prev_cl_str,
16257 prev_cml_str,
16258 prev_bl_str,
16259 delta_fa_str,
16260 delta_fa_class,
16261 delta_fs_str,
16262 delta_fs_class,
16263 delta_pl_str,
16264 delta_pl_class,
16265 delta_cl_str,
16266 delta_cl_class,
16267 delta_cml_str,
16268 delta_cml_class,
16269 delta_bl_str,
16270 delta_bl_class,
16271 delta_lines_added,
16272 delta_lines_removed,
16273 delta_lines_net_str,
16274 delta_lines_net_class,
16275 } = compute_delta_fields(
16276 prev_entry.as_ref(),
16277 &totals,
16278 files_analyzed,
16279 files_skipped,
16280 scan_delta.as_ref(),
16281 );
16282
16283 let git_commit_url = git_commit_url_for(run);
16284 let git_branch_url = git_branch_url_for(run);
16285 let scan_performed_by = scan_performed_by(run);
16286
16287 let make_rel = |p: Option<&Path>| -> Option<String> {
16289 p.and_then(|abs| abs.strip_prefix(run_dir).ok())
16290 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
16291 };
16292
16293 let run_id = &run.tool.run_id;
16294
16295 let submodule_rows: Vec<SubmoduleRow> = run
16297 .submodule_summaries
16298 .iter()
16299 .map(|s| {
16300 let safe = sanitize_project_label(&s.name);
16301 let key = format!("sub_{safe}");
16302 let sub_path = run_dir.join("submodules").join(format!("{key}.html"));
16303 SubmoduleRow {
16304 name: s.name.clone(),
16305 relative_path: s.relative_path.clone(),
16306 files_analyzed: s.files_analyzed,
16307 code_lines: s.code_lines,
16308 comment_lines: s.comment_lines,
16309 blank_lines: s.blank_lines,
16310 total_physical_lines: s.total_physical_lines,
16311 html_url: if sub_path.exists() {
16312 Some(format!("submodules/{key}.html"))
16313 } else {
16314 None
16315 },
16316 }
16317 })
16318 .collect();
16319
16320 let lang_chart_json = build_lang_chart_json(run);
16321
16322 let scan_config_rel =
16323 make_rel(scan_config_path).unwrap_or_else(|| format!("json/scan-config_{file_stem}.json"));
16324
16325 let template = ResultTemplate {
16326 version: env!("CARGO_PKG_VERSION"),
16327 report_title: run.effective_configuration.reporting.report_title.clone(),
16328 project_path: project_path.clone(),
16329 output_dir: display_path(run_dir),
16330 run_id: run_id.clone(),
16331 run_id_short: run_id
16332 .split('-')
16333 .next_back()
16334 .unwrap_or(run_id)
16335 .chars()
16336 .take(7)
16337 .collect(),
16338 files_analyzed,
16339 files_skipped,
16340 physical_lines: totals.physical_lines,
16341 code_lines: totals.code_lines,
16342 comment_lines: totals.comment_lines,
16343 blank_lines: totals.blank_lines,
16344 mixed_lines: totals.mixed_lines,
16345 functions: totals.functions,
16346 classes: totals.classes,
16347 variables: totals.variables,
16348 imports: totals.imports,
16349 html_url: make_rel(html_path),
16350 pdf_url: make_rel(pdf_path),
16351 json_url: make_rel(json_path),
16352 html_download_url: make_rel(html_path),
16353 pdf_download_url: make_rel(pdf_path),
16354 json_download_url: make_rel(json_path),
16355 html_path: html_path.map(display_path),
16356 json_path: json_path.map(display_path),
16357 prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
16358 prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
16359 prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
16360 prev_fa_str,
16361 prev_fs_str,
16362 prev_pl_str,
16363 prev_cl_str,
16364 prev_cml_str,
16365 prev_bl_str,
16366 delta_fa_str,
16367 delta_fa_class,
16368 delta_fs_str,
16369 delta_fs_class,
16370 delta_pl_str,
16371 delta_pl_class,
16372 delta_cl_str,
16373 delta_cl_class,
16374 delta_cml_str,
16375 delta_cml_class,
16376 delta_bl_str,
16377 delta_bl_class,
16378 delta_lines_added,
16379 delta_lines_removed,
16380 delta_lines_net_str,
16381 delta_lines_net_class,
16382 delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
16383 delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
16384 delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
16385 delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
16386 delta_files_total: scan_delta.as_ref().map(|d| d.files_total),
16387 delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
16388 git_branch: run.git_branch.clone(),
16389 git_branch_url,
16390 git_commit: run.git_commit_short.clone(),
16391 git_commit_long: run.git_commit_long.clone(),
16392 git_author: run.git_commit_author.clone(),
16393 git_commit_url,
16394 scan_performed_by,
16395 scan_time_display: fmt_la_time_meta(run.tool.timestamp_utc),
16396 scan_time_utc_ms: run.tool.timestamp_utc.timestamp_millis(),
16397 os_display: format!(
16398 "{} / {}",
16399 run.environment.operating_system, run.environment.architecture
16400 ),
16401 test_count: run.summary_totals.test_count,
16402 test_assertion_count: run.summary_totals.test_assertion_count,
16403 current_scan_number: prev_scan_count + 1,
16404 prev_scan_count,
16405 submodule_rows,
16406 pdf_generating: false,
16407 scan_config_url: scan_config_rel,
16408 lang_chart_json,
16409 scatter_chart_json: build_scatter_chart_json(run),
16410 semantic_chart_json: build_semantic_chart_json(run),
16411 submodule_chart_json: build_submodule_chart_json(run),
16412 has_submodule_data: !run.submodule_summaries.is_empty(),
16413 has_semantic_data: run
16414 .totals_by_language
16415 .iter()
16416 .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
16417 csp_nonce: String::new(),
16418 confluence_configured: false,
16419 server_mode: false,
16420 report_header_footer: run
16421 .effective_configuration
16422 .reporting
16423 .report_header_footer
16424 .clone(),
16425 is_offline: true,
16426 cyclomatic_complexity: run.summary_totals.cyclomatic_complexity,
16427 lsloc: run.summary_totals.lsloc,
16428 uloc: run.uloc,
16429 dryness_pct_str: run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}")),
16430 duplicate_group_count: run.duplicate_groups.len(),
16431 has_cocomo: run.cocomo.is_some(),
16432 cocomo_effort_str: run
16433 .cocomo
16434 .as_ref()
16435 .map_or(String::new(), |c| format!("{:.2}", c.effort_person_months)),
16436 cocomo_duration_str: run
16437 .cocomo
16438 .as_ref()
16439 .map_or(String::new(), |c| format!("{:.2}", c.duration_months)),
16440 cocomo_staff_str: run
16441 .cocomo
16442 .as_ref()
16443 .map_or(String::new(), |c| format!("{:.2}", c.avg_staff)),
16444 cocomo_ksloc_str: run
16445 .cocomo
16446 .as_ref()
16447 .map_or(String::new(), |c| format!("{:.2}", c.ksloc)),
16448 cocomo_mode_label: run.cocomo.as_ref().map_or_else(
16449 || "Organic".to_string(),
16450 |c| cocomo_mode_label(c.mode).to_string(),
16451 ),
16452 cocomo_mode_tooltip: run
16453 .cocomo
16454 .as_ref()
16455 .map_or(String::new(), |c| cocomo_mode_tooltip(c.mode).to_string()),
16456 complexity_alert: 0,
16457 has_coverage_data: run.summary_totals.coverage_lines_found > 0,
16458 cov_line_pct: cov_pct_str(
16459 run.summary_totals.coverage_lines_hit,
16460 run.summary_totals.coverage_lines_found,
16461 ),
16462 cov_fn_pct: cov_pct_str(
16463 run.summary_totals.coverage_functions_hit,
16464 run.summary_totals.coverage_functions_found,
16465 ),
16466 cov_branch_pct: cov_pct_str(
16467 run.summary_totals.coverage_branches_hit,
16468 run.summary_totals.coverage_branches_found,
16469 ),
16470 cov_lines_summary: cov_lines_summary_str(
16471 run.summary_totals.coverage_lines_hit,
16472 run.summary_totals.coverage_lines_found,
16473 ),
16474 };
16475
16476 if let Ok(html) = template.render() {
16477 let html = inline_offline_logos(&html);
16481 let index_path = run_dir.join("index.html");
16482 if let Err(e) = fs::write(&index_path, html) {
16483 eprintln!("[oxide-sloc] index.html write failed (non-fatal): {e:#}");
16484 }
16485 }
16486}
16487
16488fn inline_offline_logos(html: &str) -> String {
16492 use base64::Engine;
16493 let text_uri = format!(
16494 "data:image/png;base64,{}",
16495 base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_TEXT)
16496 );
16497 let small_uri = format!(
16498 "data:image/png;base64,{}",
16499 base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_SMALL)
16500 );
16501 html.replace("/images/logo/logo-text.png", &text_uri)
16502 .replace("/images/logo/small-logo.png", &small_uri)
16503}
16504
16505fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
16508 if let Some(found) = find_scan_config_in_dir_flat(&dir.join("json")) {
16510 return Some(found);
16511 }
16512 find_scan_config_in_dir_flat(dir)
16514}
16515
16516fn find_scan_config_in_dir_flat(dir: &Path) -> Option<PathBuf> {
16517 let exact = dir.join("scan-config.json");
16518 if exact.exists() {
16519 return Some(exact);
16520 }
16521 fs::read_dir(dir).ok().and_then(|entries| {
16522 entries
16523 .filter_map(std::result::Result::ok)
16524 .find(|e| {
16525 let name = e.file_name();
16526 let name = name.to_string_lossy();
16527 name.starts_with("scan-config") && name.ends_with(".json")
16528 })
16529 .map(|e| e.path())
16530 })
16531}
16532
16533#[derive(Deserialize)]
16538struct ExportPdfRequest {
16539 html: String,
16540 #[serde(default)]
16541 filename: Option<String>,
16542}
16543
16544async fn export_pdf_handler(Json(body): Json<ExportPdfRequest>) -> impl IntoResponse {
16545 let html_content = body.html;
16546 let filename = body.filename.unwrap_or_else(|| "report.pdf".to_string());
16547 if html_content.is_empty() {
16548 return (StatusCode::BAD_REQUEST, "Missing html field").into_response();
16549 }
16550 let tmp_dir = std::env::temp_dir();
16552 let html_path = tmp_dir.join(format!(
16553 "sloc-export-{}.html",
16554 uuid::Uuid::new_v4().simple()
16555 ));
16556 let pdf_path = tmp_dir.join(format!("sloc-export-{}.pdf", uuid::Uuid::new_v4().simple()));
16557 if let Err(e) = std::fs::write(&html_path, &html_content) {
16558 return (
16559 StatusCode::INTERNAL_SERVER_ERROR,
16560 format!("Failed to write temp HTML: {e}"),
16561 )
16562 .into_response();
16563 }
16564 let pdf_result = write_pdf_from_html(&html_path, &pdf_path);
16565 let _ = std::fs::remove_file(&html_path);
16566 if let Err(e) = pdf_result {
16567 let _ = std::fs::remove_file(&pdf_path);
16568 return (
16569 StatusCode::INTERNAL_SERVER_ERROR,
16570 format!("PDF generation failed: {e}"),
16571 )
16572 .into_response();
16573 }
16574 let pdf_bytes = match std::fs::read(&pdf_path) {
16575 Ok(b) => b,
16576 Err(e) => {
16577 let _ = std::fs::remove_file(&pdf_path);
16578 return (
16579 StatusCode::INTERNAL_SERVER_ERROR,
16580 format!("Failed to read PDF: {e}"),
16581 )
16582 .into_response();
16583 }
16584 };
16585 let _ = std::fs::remove_file(&pdf_path);
16586 let safe_name: String = filename
16587 .chars()
16588 .map(|c| {
16589 if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
16590 c
16591 } else {
16592 '_'
16593 }
16594 })
16595 .collect();
16596 let disposition = format!("attachment; filename=\"{safe_name}\"");
16597 (
16598 [
16599 (header::CONTENT_TYPE, "application/pdf".to_string()),
16600 (header::CONTENT_DISPOSITION, disposition),
16601 ],
16602 pdf_bytes,
16603 )
16604 .into_response()
16605}
16606
16607async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
16608 let toml_str = match toml::to_string_pretty(&state.base_config) {
16609 Ok(s) => s,
16610 Err(e) => {
16611 return (
16612 StatusCode::INTERNAL_SERVER_ERROR,
16613 format!("serialization error: {e}"),
16614 )
16615 .into_response();
16616 }
16617 };
16618 (
16619 [
16620 (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
16621 (
16622 header::CONTENT_DISPOSITION,
16623 "attachment; filename=\".oxide-sloc.toml\"",
16624 ),
16625 ],
16626 toml_str,
16627 )
16628 .into_response()
16629}
16630
16631#[derive(Serialize)]
16632struct OkResponse {
16633 ok: bool,
16634}
16635
16636#[derive(Serialize)]
16637struct SaveProfileResponse {
16638 ok: bool,
16639 id: String,
16640}
16641
16642#[derive(Serialize)]
16643struct ProfileListResponse {
16644 profiles: Vec<ScanProfile>,
16645}
16646
16647#[derive(Serialize)]
16648struct ImportConfigResponse {
16649 ok: bool,
16650 config: sloc_config::AppConfig,
16651}
16652
16653#[derive(Deserialize)]
16654struct ImportConfigBody {
16655 toml: String,
16656}
16657
16658async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
16659 match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
16660 Ok(config) => {
16661 if let Err(e) = config.validate() {
16662 return error::unprocessable_entity(&e.to_string());
16663 }
16664 Json(ImportConfigResponse { ok: true, config }).into_response()
16665 }
16666 Err(e) => error::bad_request(&format!("TOML parse error: {e}")),
16667 }
16668}
16669
16670async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
16673 let store = state.scan_profiles.lock().await;
16674 Json(ProfileListResponse {
16675 profiles: store.profiles.clone(),
16676 })
16677}
16678
16679#[derive(Deserialize)]
16680struct SaveScanProfileBody {
16681 name: String,
16682 params: serde_json::Value,
16683}
16684
16685async fn api_save_scan_profile(
16686 State(state): State<AppState>,
16687 Json(body): Json<SaveScanProfileBody>,
16688) -> impl IntoResponse {
16689 if body.name.trim().is_empty() {
16690 return error::bad_request("name must not be empty");
16691 }
16692
16693 let id = uuid::Uuid::new_v4().to_string();
16694 let profile = ScanProfile {
16695 id: id.clone(),
16696 name: body.name.trim().to_string(),
16697 created_at: chrono::Utc::now().to_rfc3339(),
16698 params: body.params,
16699 };
16700
16701 let mut store = state.scan_profiles.lock().await;
16702 store.profiles.push(profile);
16703 if let Err(e) = store.save(&state.scan_profiles_path) {
16704 tracing::warn!("failed to persist scan profiles: {e}");
16705 }
16706 drop(store);
16707
16708 (
16709 StatusCode::CREATED,
16710 Json(SaveProfileResponse { ok: true, id }),
16711 )
16712 .into_response()
16713}
16714
16715async fn api_delete_scan_profile(
16716 State(state): State<AppState>,
16717 AxumPath(id): AxumPath<String>,
16718) -> impl IntoResponse {
16719 let mut store = state.scan_profiles.lock().await;
16720 let before = store.profiles.len();
16721 store.profiles.retain(|p| p.id != id);
16722 if store.profiles.len() == before {
16723 drop(store);
16724 return error::not_found("profile not found");
16725 }
16726 if let Err(e) = store.save(&state.scan_profiles_path) {
16727 tracing::warn!("failed to persist scan profiles: {e}");
16728 }
16729 drop(store);
16730 Json(OkResponse { ok: true }).into_response()
16731}
16732
16733fn resolve_output_root(raw: Option<&str>) -> PathBuf {
16734 let value = raw.unwrap_or("out/web").trim();
16735 let path = if value.is_empty() {
16736 PathBuf::from("out/web")
16737 } else {
16738 PathBuf::from(value)
16739 };
16740
16741 if path.is_absolute() {
16742 path
16743 } else {
16744 workspace_root().join(path)
16745 }
16746}
16747
16748fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
16750 std::env::var("SLOC_GIT_CLONES_DIR")
16751 .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
16752}
16753
16754pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
16757 let safe: String = repo_url
16758 .chars()
16759 .map(|c| {
16760 if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
16761 c
16762 } else {
16763 '_'
16764 }
16765 })
16766 .take(80)
16767 .collect();
16768 clones_dir.join(safe)
16769}
16770
16771pub(crate) fn scan_path_to_artifacts(
16774 scan_path: &Path,
16775 base_config: &AppConfig,
16776 label: &str,
16777) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
16778 let mut config = base_config.clone();
16779 config.discovery.root_paths = vec![scan_path.to_path_buf()];
16780 label.clone_into(&mut config.reporting.report_title);
16781 let run = analyze(&config, "git", None, None)?;
16782 let html = render_html(&run)?;
16783 let run_id = run.tool.run_id.clone();
16784 let project_label = sanitize_project_label(label);
16785 let output_dir = resolve_output_root(None).join(format!("{project_label}_{run_id}"));
16786 let file_stem = {
16787 let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
16788 if commit.is_empty() {
16789 project_label
16790 } else {
16791 format!("{project_label}_{commit}")
16792 }
16793 };
16794 let (artifacts, _pending_pdf) = persist_run_artifacts(
16795 &run,
16796 &html,
16797 &output_dir,
16798 label,
16799 &file_stem,
16800 RunResultContext::default(),
16801 )?;
16802 Ok((run_id, artifacts, run))
16803}
16804
16805async fn restart_poll_schedules(state: &AppState) {
16807 let store = state.schedules.lock().await;
16808 let poll_schedules: Vec<_> = store
16809 .schedules
16810 .iter()
16811 .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
16812 .cloned()
16813 .collect();
16814 drop(store);
16815 for schedule in poll_schedules {
16816 let interval = schedule.interval_secs.unwrap_or(300);
16817 let st = state.clone();
16818 tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
16819 }
16820}
16821
16822async fn warn_insecure_gitlab_webhooks(state: &AppState) {
16828 if state.tls_enabled {
16829 return;
16830 }
16831 let store = state.schedules.lock().await;
16832 let has_gitlab_webhook = store.schedules.iter().any(|s| {
16833 s.kind == sloc_git::ScanScheduleKind::Webhook
16834 && s.provider == sloc_git::ScanScheduleProvider::GitLab
16835 });
16836 drop(store);
16837 if has_gitlab_webhook {
16838 tracing::warn!(
16839 "GitLab webhook schedule(s) configured but native TLS is not enabled. \
16840 GitLab sends its webhook token as a plaintext X-Gitlab-Token header; \
16841 terminate TLS here (SLOC_TLS_CERT/SLOC_TLS_KEY) or at an upstream reverse \
16842 proxy so the token is not exposed in cleartext."
16843 );
16844 }
16845}
16846
16847fn split_patterns(raw: Option<&str>) -> Vec<String> {
16848 raw.unwrap_or("")
16849 .lines()
16850 .flat_map(|line| line.split(','))
16851 .map(str::trim)
16852 .filter(|part| !part.is_empty())
16853 .map(ToOwned::to_owned)
16854 .collect()
16855}
16856
16857#[must_use]
16858pub fn build_sub_run(
16859 parent: &AnalysisRun,
16860 sub: &sloc_core::SubmoduleSummary,
16861 parent_path: &str,
16862) -> AnalysisRun {
16863 let sub_files: Vec<_> = parent
16864 .per_file_records
16865 .iter()
16866 .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
16867 .cloned()
16868 .collect();
16869 let mut config = parent.effective_configuration.clone();
16870 config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
16871
16872 let mut functions = 0u64;
16874 let mut classes = 0u64;
16875 let mut variables = 0u64;
16876 let mut imports = 0u64;
16877 let mut test_count = 0u64;
16878 let mut test_assertion_count = 0u64;
16879 let mut test_suite_count = 0u64;
16880 let mut mixed_lines_separate = 0u64;
16881 let mut coverage_lines_found = 0u64;
16882 let mut coverage_lines_hit = 0u64;
16883 let mut coverage_functions_found = 0u64;
16884 let mut coverage_functions_hit = 0u64;
16885 let mut coverage_branches_found = 0u64;
16886 let mut coverage_branches_hit = 0u64;
16887 for r in &sub_files {
16888 functions += r.raw_line_categories.functions;
16889 classes += r.raw_line_categories.classes;
16890 variables += r.raw_line_categories.variables;
16891 imports += r.raw_line_categories.imports;
16892 test_count += r.raw_line_categories.test_count;
16893 test_assertion_count += r.raw_line_categories.test_assertion_count;
16894 test_suite_count += r.raw_line_categories.test_suite_count;
16895 mixed_lines_separate += r.effective_counts.mixed_lines_separate;
16896 if let Some(cov) = &r.coverage {
16897 coverage_lines_found += u64::from(cov.lines_found);
16898 coverage_lines_hit += u64::from(cov.lines_hit);
16899 coverage_functions_found += u64::from(cov.functions_found);
16900 coverage_functions_hit += u64::from(cov.functions_hit);
16901 coverage_branches_found += u64::from(cov.branches_found);
16902 coverage_branches_hit += u64::from(cov.branches_hit);
16903 }
16904 }
16905
16906 AnalysisRun {
16907 tool: parent.tool.clone(),
16908 environment: parent.environment.clone(),
16909 effective_configuration: config,
16910 input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
16911 summary_totals: SummaryTotals {
16912 files_considered: sub.files_analyzed,
16913 files_analyzed: sub.files_analyzed,
16914 files_skipped: 0,
16915 total_physical_lines: sub.total_physical_lines,
16916 code_lines: sub.code_lines,
16917 comment_lines: sub.comment_lines,
16918 blank_lines: sub.blank_lines,
16919 mixed_lines_separate,
16920 functions,
16921 classes,
16922 variables,
16923 imports,
16924 test_count,
16925 test_assertion_count,
16926 test_suite_count,
16927 coverage_lines_found,
16928 coverage_lines_hit,
16929 coverage_functions_found,
16930 coverage_functions_hit,
16931 coverage_branches_found,
16932 coverage_branches_hit,
16933 cyclomatic_complexity: 0,
16934 lsloc: None,
16935 ..Default::default()
16936 },
16937 totals_by_language: sub.language_summaries.clone(),
16938 per_file_records: sub_files,
16939 skipped_file_records: vec![],
16940 warnings: vec![],
16941 submodule_summaries: vec![],
16942 git_commit_short: sub.git_commit_short.clone(),
16943 git_commit_long: sub.git_commit_long.clone(),
16944 git_branch: sub.git_branch.clone(),
16945 git_commit_author: sub.git_commit_author.clone(),
16946 git_commit_date: sub.git_commit_date.clone(),
16947 git_tags: None,
16948 git_nearest_tag: None,
16949 git_remote_url: sub.git_remote_url.clone(),
16950 style_summary: None,
16951 cocomo: None,
16952 uloc: 0,
16953 dryness_pct: None,
16954 duplicate_groups: vec![],
16955 duplicates_excluded: 0,
16956 }
16957}
16958
16959#[must_use]
16960pub fn sanitize_project_label(raw: &str) -> String {
16961 let candidate = raw
16964 .split(['/', '\\'])
16965 .rfind(|s| !s.is_empty())
16966 .unwrap_or("project");
16967
16968 let mut value = String::with_capacity(candidate.len());
16969 for ch in candidate.chars() {
16970 if ch.is_ascii_alphanumeric() {
16971 value.push(ch.to_ascii_lowercase());
16972 } else {
16973 value.push('-');
16974 }
16975 }
16976
16977 let compact = value.trim_matches('-').to_string();
16978 if compact.is_empty() {
16979 "project".to_string()
16980 } else {
16981 compact
16982 }
16983}
16984
16985fn strip_unc_prefix(path: PathBuf) -> PathBuf {
16988 let s = path.to_string_lossy();
16989 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
16990 return PathBuf::from(format!(r"\\{rest}"));
16991 }
16992 if let Some(rest) = s.strip_prefix(r"\\?\") {
16993 return PathBuf::from(rest);
16994 }
16995 path
16996}
16997
16998fn remote_to_commit_url(remote: &str, sha: &str) -> Option<String> {
17001 let base = if let Some(rest) = remote.strip_prefix("git@") {
17002 let (host, path) = rest.split_once(':')?;
17003 format!("https://{}/{}", host, path.trim_end_matches(".git"))
17004 } else if remote.starts_with("https://") || remote.starts_with("http://") {
17005 remote
17006 .trim_end_matches('/')
17007 .trim_end_matches(".git")
17008 .to_owned()
17009 } else {
17010 return None;
17011 };
17012 let base = base.trim_end_matches('/');
17013 if base.contains("gitlab.com") || base.contains("gitlab.") {
17015 Some(format!("{base}/-/commit/{sha}"))
17016 } else if base.contains("bitbucket.org") {
17017 Some(format!("{base}/commits/{sha}"))
17018 } else {
17019 Some(format!("{base}/commit/{sha}"))
17020 }
17021}
17022
17023fn remote_to_branch_url(remote: &str, branch: &str) -> Option<String> {
17026 let base = if let Some(rest) = remote.strip_prefix("git@") {
17027 let (host, path) = rest.split_once(':')?;
17028 format!("https://{}/{}", host, path.trim_end_matches(".git"))
17029 } else if remote.starts_with("https://") || remote.starts_with("http://") {
17030 remote
17031 .trim_end_matches('/')
17032 .trim_end_matches(".git")
17033 .to_owned()
17034 } else {
17035 return None;
17036 };
17037 let base = base.trim_end_matches('/');
17038 if base.contains("gitlab.com") || base.contains("gitlab.") {
17039 Some(format!("{base}/-/tree/{branch}"))
17040 } else {
17041 Some(format!("{base}/tree/{branch}"))
17042 }
17043}
17044
17045fn display_path(path: &Path) -> String {
17046 let s = path.to_string_lossy();
17047 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
17052 return format!(r"\\{rest}");
17053 }
17054 if let Some(rest) = s.strip_prefix(r"\\?\") {
17055 return rest.to_owned();
17056 }
17057 s.into_owned()
17058}
17059
17060fn sanitize_path_str(s: &str) -> String {
17061 if let Some(rest) = s.strip_prefix("//?/UNC/") {
17065 return format!("//{rest}");
17066 }
17067 if let Some(rest) = s.strip_prefix("//?/") {
17068 return rest.to_owned();
17069 }
17070 display_path(Path::new(s))
17071}
17072
17073fn workspace_root() -> PathBuf {
17074 if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
17076 let p = PathBuf::from(root);
17077 if p.is_dir() {
17078 return p;
17079 }
17080 }
17081
17082 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
17085}
17086
17087fn make_git_label(repo: &str, ref_name: &str) -> String {
17089 if repo.is_empty() || ref_name.is_empty() {
17090 return String::new();
17091 }
17092 let base = repo
17093 .trim_end_matches('/')
17094 .trim_end_matches(".git")
17095 .rsplit('/')
17096 .next()
17097 .unwrap_or("repo");
17098 let ref_safe: String = ref_name
17099 .chars()
17100 .map(|c| {
17101 if c.is_alphanumeric() || c == '-' || c == '.' {
17102 c
17103 } else {
17104 '_'
17105 }
17106 })
17107 .collect();
17108 format!("{base}_at_{ref_safe}_sloc")
17109}
17110
17111fn desktop_dir() -> PathBuf {
17113 if let Ok(profile) = std::env::var("USERPROFILE") {
17114 let p = PathBuf::from(profile).join("Desktop");
17115 if p.exists() {
17116 return p;
17117 }
17118 }
17119 if let Ok(home) = std::env::var("HOME") {
17120 let p = PathBuf::from(home).join("Desktop");
17121 if p.exists() {
17122 return p;
17123 }
17124 }
17125 workspace_root().join("out").join("web")
17126}
17127
17128fn resolve_input_path(raw: &str) -> PathBuf {
17129 let trimmed = raw.trim();
17130 if trimmed.is_empty() {
17131 return workspace_root().join("samples").join("basic");
17132 }
17133
17134 let candidate = PathBuf::from(trimmed);
17135 let resolved = if candidate.is_absolute() {
17136 candidate
17137 } else {
17138 let rooted = workspace_root().join(&candidate);
17139 if rooted.exists() {
17140 rooted
17141 } else {
17142 workspace_root().join(candidate)
17143 }
17144 };
17145
17146 let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
17149 PathBuf::from(display_path(&canonical))
17150}
17151
17152fn dir_size_bytes(path: &Path) -> u64 {
17153 let mut total = 0u64;
17154 if let Ok(rd) = fs::read_dir(path) {
17155 for entry in rd.filter_map(Result::ok) {
17156 let p = entry.path();
17157 if p.is_file() {
17158 if let Ok(meta) = p.metadata() {
17159 total += meta.len();
17160 }
17161 } else if p.is_dir() {
17162 total += dir_size_bytes(&p);
17163 }
17164 }
17165 }
17166 total
17167}
17168
17169#[allow(clippy::cast_precision_loss)] fn format_dir_size(bytes: u64) -> String {
17171 if bytes >= 1_073_741_824 {
17172 format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
17173 } else if bytes >= 1_048_576 {
17174 format!("{:.1} MB", bytes as f64 / 1_048_576.0)
17175 } else if bytes >= 1_024 {
17176 format!("{:.0} KB", bytes as f64 / 1_024.0)
17177 } else {
17178 format!("{bytes} B")
17179 }
17180}
17181
17182fn render_submodule_chips(
17183 root: &Path,
17184 submodules: &[(String, std::path::PathBuf)],
17185 out: &mut String,
17186) {
17187 use std::fmt::Write as _;
17188 let count = submodules.len();
17189 out.push_str(r#"<div class="submodule-preview-strip">"#);
17190 write!(
17191 out,
17192 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>"#,
17193 if count == 1 { "" } else { "s" }
17194 )
17195 .ok();
17196 out.push_str(r#"<div class="submodule-preview-chips">"#);
17197 for (sub_name, sub_rel_path) in submodules {
17198 let sub_abs = root.join(sub_rel_path);
17199 let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
17200 let mut sub_stats = PreviewStats::default();
17201 let mut sub_rows: Vec<PreviewRow> = Vec::new();
17202 let mut sub_langs: Vec<&'static str> = Vec::new();
17203 let mut sub_budget = PreviewBudget {
17204 shown: 0,
17205 max_entries: 2000,
17206 max_depth: 9,
17207 };
17208 let mut sub_next_id = 1usize;
17209 let _ = collect_preview_rows(
17210 &sub_abs,
17211 &sub_abs,
17212 0,
17213 None,
17214 &mut sub_next_id,
17215 &mut sub_budget,
17216 &mut sub_stats,
17217 &mut sub_rows,
17218 &mut sub_langs,
17219 &[],
17220 &[],
17221 );
17222 let stats_json = format!(
17223 r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
17224 sub_stats.directories,
17225 sub_stats.files,
17226 sub_stats.supported,
17227 sub_stats.skipped,
17228 sub_stats.unsupported
17229 );
17230 write!(
17231 out,
17232 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>"#,
17233 escape_html(sub_name),
17234 escape_html(&sub_rel_path.to_string_lossy()),
17235 escape_html(&sub_size),
17236 escape_html(&stats_json),
17237 escape_html(sub_name),
17238 escape_html(&sub_size),
17239 )
17240 .ok();
17241 }
17242 out.push_str(
17243 r#"</div><button type="button" class="submodule-base-repo-btn" style="display:none">↑ Base repo</button>"#,
17244 );
17245 out.push_str(r"</div>");
17246}
17247
17248fn render_multi_repo_warning(root: &Path, layout: &sloc_core::RepositoryLayout, out: &mut String) {
17252 use std::fmt::Write as _;
17253 const MAX_LISTED: usize = 5;
17254 let total = layout.nested_repos.len();
17255
17256 out.push_str(r#"<div class="preview-warning" data-multi-repo="1">"#);
17257 if layout.root_is_repo {
17258 write!(
17259 out,
17260 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>",
17261 if total == 1 { "repository" } else { "repositories" }
17262 )
17263 .ok();
17264 } else {
17265 write!(
17266 out,
17267 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>"
17268 )
17269 .ok();
17270 }
17271
17272 out.push_str(r#"<div class="repo-pick-row">"#);
17273 for rel in layout.nested_repos.iter().take(MAX_LISTED) {
17274 let abs = root.join(rel);
17275 let abs_display = display_path(&abs);
17276 let label = rel.to_string_lossy().replace('\\', "/");
17277 write!(
17278 out,
17279 r#"<button type="button" class="repo-pick" data-repo-path="{}">{}</button>"#,
17280 escape_html(&abs_display),
17281 escape_html(&label)
17282 )
17283 .ok();
17284 }
17285 if total > MAX_LISTED {
17286 write!(
17287 out,
17288 r#"<span class="repo-pick-more">and {} more</span>"#,
17289 total - MAX_LISTED
17290 )
17291 .ok();
17292 }
17293 out.push_str(r"</div>");
17294
17295 out.push_str(r#"<label class="multi-repo-ack-label"><input type="checkbox" class="multi-repo-ack" /> I understand — scan this folder anyway</label>"#);
17296 out.push_str(r"</div>");
17297}
17298
17299fn render_language_pills_row(languages: &[&str], out: &mut String) {
17300 use std::fmt::Write as _;
17301 if languages.is_empty() {
17302 out.push_str(
17303 r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
17304 );
17305 return;
17306 }
17307 out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
17308 for language in languages {
17309 if let Some(icon) = language_icon_file(language) {
17310 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();
17311 } else if let Some(svg) = language_inline_svg(language) {
17312 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();
17313 } else {
17314 write!(
17315 out,
17316 r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
17317 escape_html(&language.to_ascii_lowercase()),
17318 escape_html(language)
17319 )
17320 .ok();
17321 }
17322 }
17323}
17324
17325#[allow(clippy::too_many_lines)]
17326fn build_preview_html(
17327 root: &Path,
17328 include_patterns: &[String],
17329 exclude_patterns: &[String],
17330) -> Result<String> {
17331 if !root.exists() {
17332 return Ok(format!(
17333 r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
17334 escape_html(&display_path(root))
17335 ));
17336 }
17337
17338 let _selected = display_path(root);
17339 let mut stats = PreviewStats::default();
17340 let mut rows = Vec::new();
17341 let mut languages = Vec::new();
17342 let mut budget = PreviewBudget {
17343 shown: 0,
17344 max_entries: 600,
17345 max_depth: 9,
17346 };
17347 let mut next_row_id = 1usize;
17348
17349 let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
17350 || root.to_string_lossy().into_owned(),
17351 std::string::ToString::to_string,
17352 );
17353 let root_modified = root
17354 .metadata()
17355 .ok()
17356 .and_then(|meta| meta.modified().ok())
17357 .map_or_else(|| "-".to_string(), format_system_time);
17358
17359 rows.push(PreviewRow {
17360 row_id: 0,
17361 parent_row_id: None,
17362 depth: 0,
17363 name: format!("{root_name}/"),
17364 kind: PreviewKind::Dir,
17365 is_dir: true,
17366 language: None,
17367 modified: root_modified,
17368 type_label: "Directory".to_string(),
17369 });
17370 collect_preview_rows(
17371 root,
17372 root,
17373 0,
17374 Some(0),
17375 &mut next_row_id,
17376 &mut budget,
17377 &mut stats,
17378 &mut rows,
17379 &mut languages,
17380 include_patterns,
17381 exclude_patterns,
17382 )?;
17383
17384 let root_size = format_dir_size(dir_size_bytes(root));
17385
17386 let mut out = String::new();
17387 write!(
17388 out,
17389 r#"<div class="explorer-wrap" data-project-size="{}">"#,
17390 escape_html(&root_size)
17391 )
17392 .ok();
17393 out.push_str(r#"<div class="explorer-toolbar compact">"#);
17394 out.push_str(r#"<div class="explorer-title-group">"#);
17395 out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
17396 out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
17397 out.push_str(r"</div></div>");
17398
17399 out.push_str(r#"<div class="scope-stats">"#);
17400 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();
17401 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();
17402 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();
17403 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();
17404 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();
17405 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>"#);
17406 out.push_str(r"</div>");
17407
17408 let submodules = sloc_core::detect_submodules(root);
17409 if !submodules.is_empty() {
17410 render_submodule_chips(root, &submodules, &mut out);
17411 }
17412
17413 let repo_layout = sloc_core::detect_repository_layout(root);
17414 if repo_layout.has_multiple_repos() {
17415 render_multi_repo_warning(root, &repo_layout, &mut out);
17416 }
17417
17418 out.push_str(r#"<div class="scope-info-row">"#);
17419 out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
17420 render_language_pills_row(&languages, &mut out);
17421 out.push_str(r"</div></div>");
17422 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>"#);
17423 out.push_str(r"</div>");
17424
17425 out.push_str(r#"<div class="file-explorer-shell">"#);
17426 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>"#);
17427 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>"#);
17428 out.push_str(r#"<div class="file-explorer-tree">"#);
17429 for row in rows {
17430 let status_label = row.kind.label();
17431 let lang_attr = row.language.unwrap_or("");
17432 let toggle_html = if row.is_dir {
17433 r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
17434 .to_string()
17435 } else {
17436 r#"<span class="tree-bullet">•</span>"#.to_string()
17437 };
17438 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();
17439 }
17440 if budget.shown >= budget.max_entries {
17441 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>"#);
17442 }
17443 out.push_str(r"</div></div></div>");
17444
17445 Ok(out)
17446}
17447
17448#[derive(Default)]
17449struct PreviewStats {
17450 directories: usize,
17451 files: usize,
17452 supported: usize,
17453 skipped: usize,
17454 unsupported: usize,
17455}
17456
17457struct PreviewRow {
17458 row_id: usize,
17459 parent_row_id: Option<usize>,
17460 depth: usize,
17461 name: String,
17462 kind: PreviewKind,
17463 is_dir: bool,
17464 language: Option<&'static str>,
17465 modified: String,
17466 type_label: String,
17467}
17468
17469#[derive(Copy, Clone)]
17470enum PreviewKind {
17471 Dir,
17472 Supported,
17473 Skipped,
17474 Unsupported,
17475}
17476
17477impl PreviewKind {
17478 const fn filter_key(self) -> &'static str {
17479 match self {
17480 Self::Dir => "dir",
17481 Self::Supported => "supported",
17482 Self::Skipped => "skipped",
17483 Self::Unsupported => "unsupported",
17484 }
17485 }
17486
17487 const fn label(self) -> &'static str {
17488 match self {
17489 Self::Dir => "dir",
17490 Self::Supported => "supported",
17491 Self::Skipped => "skipped by policy",
17492 Self::Unsupported => "unsupported",
17493 }
17494 }
17495
17496 const fn badge_class(self) -> &'static str {
17497 match self {
17498 Self::Dir => "badge badge-dir",
17499 Self::Supported => "badge badge-scan",
17500 Self::Skipped => "badge badge-skip",
17501 Self::Unsupported => "badge badge-unsupported",
17502 }
17503 }
17504
17505 const fn node_class(self) -> &'static str {
17506 match self {
17507 Self::Dir => "tree-node-dir",
17508 Self::Supported => "tree-node-supported",
17509 Self::Skipped => "tree-node-skipped",
17510 Self::Unsupported => "tree-node-unsupported",
17511 }
17512 }
17513}
17514
17515struct PreviewBudget {
17516 shown: usize,
17517 max_entries: usize,
17518 max_depth: usize,
17519}
17520
17521#[allow(clippy::too_many_arguments)]
17524fn handle_preview_dir_entry(
17525 root: &Path,
17526 path: &Path,
17527 name: &str,
17528 modified: String,
17529 depth: usize,
17530 parent_row_id: Option<usize>,
17531 row_id: usize,
17532 next_row_id: &mut usize,
17533 budget: &mut PreviewBudget,
17534 stats: &mut PreviewStats,
17535 rows: &mut Vec<PreviewRow>,
17536 languages: &mut Vec<&'static str>,
17537 include_patterns: &[String],
17538 exclude_patterns: &[String],
17539) -> Result<()> {
17540 let relative = preview_relative_path(root, path);
17541 if should_skip_preview_directory(&relative, exclude_patterns) {
17542 return Ok(());
17543 }
17544 stats.directories += 1;
17545 rows.push(PreviewRow {
17546 row_id,
17547 parent_row_id,
17548 depth: depth + 1,
17549 name: format!("{name}/"),
17550 kind: PreviewKind::Dir,
17551 is_dir: true,
17552 language: None,
17553 modified,
17554 type_label: "Directory".to_string(),
17555 });
17556 budget.shown += 1;
17557 if !matches!(name, ".git" | "node_modules" | "target") {
17558 collect_preview_rows(
17559 root,
17560 path,
17561 depth + 1,
17562 Some(row_id),
17563 next_row_id,
17564 budget,
17565 stats,
17566 rows,
17567 languages,
17568 include_patterns,
17569 exclude_patterns,
17570 )?;
17571 }
17572 Ok(())
17573}
17574
17575#[allow(clippy::too_many_arguments)]
17577fn handle_preview_file_entry(
17578 root: &Path,
17579 path: &Path,
17580 name: &str,
17581 modified: String,
17582 depth: usize,
17583 parent_row_id: Option<usize>,
17584 row_id: usize,
17585 budget: &mut PreviewBudget,
17586 stats: &mut PreviewStats,
17587 rows: &mut Vec<PreviewRow>,
17588 languages: &mut Vec<&'static str>,
17589 include_patterns: &[String],
17590 exclude_patterns: &[String],
17591) {
17592 let relative = preview_relative_path(root, path);
17593 if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
17594 return;
17595 }
17596 stats.files += 1;
17597 let kind = classify_preview_file(name);
17598 match kind {
17599 PreviewKind::Supported => stats.supported += 1,
17600 PreviewKind::Skipped => stats.skipped += 1,
17601 PreviewKind::Unsupported => stats.unsupported += 1,
17602 PreviewKind::Dir => {}
17603 }
17604 let language = detect_language_name(name);
17605 if let Some(lang) = language
17606 && !languages.contains(&lang)
17607 {
17608 languages.push(lang);
17609 }
17610 rows.push(PreviewRow {
17611 row_id,
17612 parent_row_id,
17613 depth: depth + 1,
17614 name: name.to_owned(),
17615 kind,
17616 is_dir: false,
17617 language,
17618 modified,
17619 type_label: preview_type_label(name, language, kind),
17620 });
17621 budget.shown += 1;
17622}
17623
17624#[allow(clippy::too_many_arguments)]
17625#[allow(clippy::too_many_lines)]
17626fn collect_preview_rows(
17627 root: &Path,
17628 dir: &Path,
17629 depth: usize,
17630 parent_row_id: Option<usize>,
17631 next_row_id: &mut usize,
17632 budget: &mut PreviewBudget,
17633 stats: &mut PreviewStats,
17634 rows: &mut Vec<PreviewRow>,
17635 languages: &mut Vec<&'static str>,
17636 include_patterns: &[String],
17637 exclude_patterns: &[String],
17638) -> Result<()> {
17639 if depth >= budget.max_depth || budget.shown >= budget.max_entries {
17640 return Ok(());
17641 }
17642
17643 let mut entries = fs::read_dir(dir)
17644 .with_context(|| format!("failed to read directory {}", dir.display()))?
17645 .filter_map(std::result::Result::ok)
17646 .collect::<Vec<_>>();
17647 entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
17648
17649 for entry in entries {
17650 if budget.shown >= budget.max_entries {
17651 break;
17652 }
17653
17654 let path = entry.path();
17655 let name = entry.file_name().to_string_lossy().into_owned();
17656 let Ok(metadata) = entry.metadata() else {
17657 continue;
17658 };
17659 let row_id = *next_row_id;
17660 *next_row_id += 1;
17661 let modified = metadata
17662 .modified()
17663 .ok()
17664 .map_or_else(|| "-".to_string(), format_system_time);
17665
17666 if metadata.is_dir() {
17667 handle_preview_dir_entry(
17668 root,
17669 &path,
17670 &name,
17671 modified,
17672 depth,
17673 parent_row_id,
17674 row_id,
17675 next_row_id,
17676 budget,
17677 stats,
17678 rows,
17679 languages,
17680 include_patterns,
17681 exclude_patterns,
17682 )?;
17683 continue;
17684 }
17685
17686 if metadata.is_file() {
17687 handle_preview_file_entry(
17688 root,
17689 &path,
17690 &name,
17691 modified,
17692 depth,
17693 parent_row_id,
17694 row_id,
17695 budget,
17696 stats,
17697 rows,
17698 languages,
17699 include_patterns,
17700 exclude_patterns,
17701 );
17702 }
17703 }
17704
17705 Ok(())
17706}
17707
17708fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
17709 if let Some(language) = language {
17710 return format!("{language} source");
17711 }
17712 let lower = name.to_ascii_lowercase();
17713 let ext = Path::new(&lower)
17714 .extension()
17715 .and_then(|e| e.to_str())
17716 .unwrap_or("");
17717 match kind {
17718 PreviewKind::Skipped => {
17719 if lower.ends_with(".min.js") {
17720 "Minified asset".to_string()
17721 } else if [
17722 "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
17723 ]
17724 .contains(&ext)
17725 {
17726 "Binary or archive".to_string()
17727 } else {
17728 "Skipped file".to_string()
17729 }
17730 }
17731 PreviewKind::Unsupported => {
17732 if ext.is_empty() {
17733 "Unsupported file".to_string()
17734 } else {
17735 format!("{} file", ext.to_ascii_uppercase())
17736 }
17737 }
17738 PreviewKind::Supported => "Supported source".to_string(),
17739 PreviewKind::Dir => "Directory".to_string(),
17740 }
17741}
17742
17743fn format_system_time(time: SystemTime) -> String {
17744 #[allow(clippy::cast_possible_wrap)]
17745 let secs = match time.duration_since(UNIX_EPOCH) {
17746 Ok(duration) => duration.as_secs() as i64,
17747 Err(_) => return "-".to_string(),
17748 };
17749 let days = secs.div_euclid(86_400);
17750 let secs_of_day = secs.rem_euclid(86_400);
17751 let (year, month, day) = civil_from_days(days);
17752 let hour = secs_of_day / 3_600;
17753 let minute = (secs_of_day % 3_600) / 60;
17754 format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
17755}
17756
17757#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
17758fn civil_from_days(days: i64) -> (i32, u32, u32) {
17759 let z = days + 719_468;
17760 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
17761 let doe = z - era * 146_097;
17762 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
17763 let y = yoe + era * 400;
17764 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
17765 let mp = (5 * doy + 2) / 153;
17766 let d = doy - (153 * mp + 2) / 5 + 1;
17767 let m = mp + if mp < 10 { 3 } else { -9 };
17768 let year = y + i64::from(m <= 2);
17769 (year as i32, m as u32, d as u32)
17770}
17771
17772#[allow(clippy::case_sensitive_file_extension_comparisons)]
17775fn detect_language_name(name: &str) -> Option<&'static str> {
17776 let lower = name.to_ascii_lowercase();
17777 if lower.ends_with(".c") || lower.ends_with(".h") {
17778 Some("C")
17779 } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
17780 .iter()
17781 .any(|s| lower.ends_with(s))
17782 {
17783 Some("C++")
17784 } else if lower.ends_with(".cs") {
17785 Some("C#")
17786 } else if lower.ends_with(".py") {
17787 Some("Python")
17788 } else if lower.ends_with(".sh") {
17789 Some("Shell")
17790 } else if [".ps1", ".psm1", ".psd1"]
17791 .iter()
17792 .any(|s| lower.ends_with(s))
17793 {
17794 Some("PowerShell")
17795 } else {
17796 None
17797 }
17798}
17799
17800fn language_icon_file(language: &str) -> Option<&'static str> {
17801 match language {
17802 "C" => Some("c.png"),
17803 "C++" => Some("cpp.png"),
17804 "C#" => Some("c-sharp.png"),
17805 "Python" => Some("python.png"),
17806 "Shell" => Some("shell.png"),
17807 "PowerShell" => Some("powershell.png"),
17808 "JavaScript" => Some("java-script.png"),
17809 "HTML" => Some("html-5.png"),
17810 "Java" => Some("java.png"),
17811 "Visual Basic" => Some("visual-basic.png"),
17812 "Assembly" => Some("asm.png"),
17813 "Go" => Some("go.png"),
17814 "R" => Some("r.png"),
17815 "XML" => Some("xml.png"),
17816 "Groovy" => Some("groovy.png"),
17817 "Dockerfile" => Some("docker.png"),
17818 "Makefile" => Some("makefile.svg"),
17819 "Perl" => Some("perl.svg"),
17820 _ => None,
17821 }
17822}
17823
17824fn language_inline_svg(language: &str) -> Option<&'static str> {
17829 match language {
17830 "Rust" => Some(
17831 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>"##,
17832 ),
17833 "TypeScript" => Some(
17834 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>"##,
17835 ),
17836 _ => None,
17837 }
17838}
17839
17840#[allow(clippy::case_sensitive_file_extension_comparisons)]
17843fn classify_preview_file(name: &str) -> PreviewKind {
17844 let lower = name.to_ascii_lowercase();
17845
17846 let scannable = [
17847 ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
17848 ".psm1", ".psd1",
17849 ]
17850 .iter()
17851 .any(|suffix| lower.ends_with(suffix));
17852
17853 if scannable {
17854 PreviewKind::Supported
17855 } else if lower.ends_with(".min.js")
17856 || lower.ends_with(".lock")
17857 || lower.ends_with(".png")
17858 || lower.ends_with(".jpg")
17859 || lower.ends_with(".jpeg")
17860 || lower.ends_with(".gif")
17861 || lower.ends_with(".zip")
17862 || lower.ends_with(".pdf")
17863 || lower.ends_with(".pyc")
17864 || lower.ends_with(".xz")
17865 || lower.ends_with(".tar")
17866 || lower.ends_with(".gz")
17867 {
17868 PreviewKind::Skipped
17869 } else {
17870 PreviewKind::Unsupported
17871 }
17872}
17873
17874fn preview_relative_path(root: &Path, path: &Path) -> String {
17875 path.strip_prefix(root)
17876 .ok()
17877 .unwrap_or(path)
17878 .to_string_lossy()
17879 .replace('\\', "/")
17880 .trim_matches('/')
17881 .to_string()
17882}
17883
17884fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
17885 if relative.is_empty() {
17886 return false;
17887 }
17888
17889 exclude_patterns.iter().any(|pattern| {
17890 wildcard_match(pattern, relative)
17891 || wildcard_match(pattern, &format!("{relative}/"))
17892 || wildcard_match(pattern, &format!("{relative}/placeholder"))
17893 })
17894}
17895
17896fn should_include_preview_file(
17897 relative: &str,
17898 include_patterns: &[String],
17899 exclude_patterns: &[String],
17900) -> bool {
17901 if relative.is_empty() {
17902 return true;
17903 }
17904
17905 let included = include_patterns.is_empty()
17906 || include_patterns
17907 .iter()
17908 .any(|pattern| wildcard_match(pattern, relative));
17909 let excluded = exclude_patterns
17910 .iter()
17911 .any(|pattern| wildcard_match(pattern, relative));
17912
17913 included && !excluded
17914}
17915
17916fn wildcard_match(pattern: &str, candidate: &str) -> bool {
17917 let pattern = pattern.trim().replace('\\', "/");
17918 let candidate = candidate.trim().replace('\\', "/");
17919 let p = pattern.as_bytes();
17920 let c = candidate.as_bytes();
17921 let mut pi = 0usize;
17922 let mut ci = 0usize;
17923 let mut star: Option<usize> = None;
17924 let mut star_match = 0usize;
17925
17926 while ci < c.len() {
17927 if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
17928 pi += 1;
17929 ci += 1;
17930 } else if pi < p.len() && p[pi] == b'*' {
17931 while pi < p.len() && p[pi] == b'*' {
17932 pi += 1;
17933 }
17934 star = Some(pi);
17935 star_match = ci;
17936 } else if let Some(star_pi) = star {
17937 star_match += 1;
17938 ci = star_match;
17939 pi = star_pi;
17940 } else {
17941 return false;
17942 }
17943 }
17944
17945 while pi < p.len() && p[pi] == b'*' {
17946 pi += 1;
17947 }
17948
17949 pi == p.len()
17950}
17951
17952fn escape_html(value: &str) -> String {
17953 value
17954 .replace('&', "&")
17955 .replace('<', "<")
17956 .replace('>', ">")
17957 .replace('"', """)
17958 .replace('\'', "'")
17959}
17960
17961#[derive(Clone)]
17962struct SubmoduleRow {
17963 name: String,
17964 relative_path: String,
17965 files_analyzed: u64,
17966 code_lines: u64,
17967 comment_lines: u64,
17968 blank_lines: u64,
17969 total_physical_lines: u64,
17970 html_url: Option<String>,
17971}
17972
17973#[derive(Template)]
17974#[template(
17975 source = r##"
17976<!doctype html>
17977<html lang="en">
17978<head>
17979 <meta charset="utf-8">
17980 <title>OxideSLOC | tmp-sloc</title>
17981 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
17982 <style nonce="{{ csp_nonce }}">
17983 :root {
17984 --bg: #efe9e2;
17985 --surface: #fcfaf7;
17986 --surface-2: #f7f0e8;
17987 --surface-3: #efe3d5;
17988 --line: #dfcfbf;
17989 --line-strong: #cfb29c;
17990 --text: #2f241c;
17991 --muted: #6f6257;
17992 --muted-2: #917f71;
17993 --nav: #b85d33;
17994 --nav-2: #7a371b;
17995 --accent: #2563eb;
17996 --accent-2: #1d4ed8;
17997 --oxide: #b85d33;
17998 --oxide-2: #8f4220;
17999 --success-bg: #eaf9ee;
18000 --success-text: #1c8746;
18001 --warn-bg: #fff2d8;
18002 --warn-text: #926000;
18003 --danger-bg: #fdeaea;
18004 --danger-text: #b33b3b;
18005 --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
18006 --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
18007 --radius: 14px;
18008 }
18009
18010 body.dark-theme {
18011 --bg: #1b1511;
18012 --surface: #261c17;
18013 --surface-2: #2d221d;
18014 --surface-3: #372922;
18015 --line: #524238;
18016 --line-strong: #6c5649;
18017 --text: #f5ece6;
18018 --muted: #c7b7aa;
18019 --muted-2: #aa9485;
18020 --nav: #b85d33;
18021 --nav-2: #7a371b;
18022 --accent: #6f9bff;
18023 --accent-2: #4a78ee;
18024 --oxide: #d37a4c;
18025 --oxide-2: #b35428;
18026 --success-bg: #163927;
18027 --success-text: #8fe2a8;
18028 --warn-bg: #3c2d11;
18029 --warn-text: #f3cb75;
18030 --danger-bg: #3d1f1f;
18031 --danger-text: #ff9f9f;
18032 --shadow: 0 14px 28px rgba(0,0,0,0.28);
18033 --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
18034 }
18035
18036 * { box-sizing: border-box; }
18037 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); }
18038 html { overflow-y: scroll; }
18039 body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
18040 .top-nav, .page, .loading { position: relative; z-index: 2; }
18041 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
18042 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
18043 .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); }
18044 .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; }
18045 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
18046 .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)); }
18047 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
18048 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
18049 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
18050 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
18051 .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; }
18052 .nav-project-pill.visible { display:inline-flex; }
18053 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
18054 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
18055 .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
18056 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
18057 @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; } }
18058 .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; }
18059 a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
18060 .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; }
18061 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
18062 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
18063 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
18064 .theme-toggle .icon-sun { display:none; }
18065 body.dark-theme .theme-toggle .icon-sun { display:block; }
18066 body.dark-theme .theme-toggle .icon-moon { display:none; }
18067 .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;}
18068 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
18069 .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);}
18070 .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;}
18071 .settings-close:hover{color:var(--text);background:var(--surface-2);}
18072 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
18073 .settings-modal-body{padding:14px 16px 16px;}
18074 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
18075 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
18076 .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;}
18077 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
18078 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
18079 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
18080 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
18081 .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;}
18082 .tz-select:focus{border-color:var(--oxide);}
18083 .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; }
18084 .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;}
18085 .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; width: 100%; display: flex; flex-direction: column; }
18086 @media (max-width: 1920px) { .top-nav-inner { max-width: 1500px; } .page { max-width: 1500px; } }
18087 .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
18088 .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
18089 .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; }
18090 .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
18091 body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
18092 .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
18093 .wb-stats-header { padding: 10px 24px 0; }
18094 .wb-stats-title { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
18095 .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
18096 .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; }
18097 .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
18098 body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
18099 .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
18100 .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
18101 .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; }
18102 body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
18103 .ws-stat-analyzers { position: relative; }
18104 .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; }
18105 .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
18106 .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
18107 .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
18108 .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
18109 .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; }
18110 body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
18111 .ws-divider { display: none; }
18112 .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%; }
18113 .ws-path-link:hover { color:var(--oxide); }
18114 body.dark-theme .ws-path-link { color:var(--oxide); }
18115 .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
18116 .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
18117 .ws-stat-clamp { max-width: 200px; overflow: hidden; }
18118 .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
18119 .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
18120 .ws-mini-box-sm .ws-mini-label { font-size:9px; }
18121 .ws-mini-box-sm .ws-mini-value { font-size:13px; }
18122 .ws-mini-box-lg { flex:2 1 0; }
18123 .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
18124 .ws-mini-box-br { flex:1.5 1 0; }
18125 .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; }
18126 .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; flex-shrink:0; margin-right:10px; }
18127 .path-scope-grid { display:grid; grid-template-columns: calc(42% - 7px) auto auto 1px 1fr; gap:0 8px; align-items:center; }
18128 #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; }
18129 .path-scope-grid > input[type=text] { width:100%; min-width:0; }
18130 .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; }
18131 .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
18132 .git-source-banner strong { font-weight:800; color:var(--text); }
18133 .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; }
18134 body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
18135 .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
18136 .git-source-banner a:hover { text-decoration:underline; }
18137 .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
18138 .path-scope-sep { background:var(--line); margin:4px 14px; }
18139 .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
18140 .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
18141 .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
18142 .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
18143 .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
18144 .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
18145 .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; }
18146 .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
18147 body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
18148 .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
18149 .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; }
18150 .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
18151 .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
18152 [data-wb-tip] { cursor:help; }
18153 .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
18154 .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
18155 .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; }
18156 .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
18157 .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
18158 body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
18159 .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; }
18160 .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); }
18161 .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
18162 .side-info-card { padding: 18px; }
18163 .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
18164 .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
18165 .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
18166 .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
18167 .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); }
18168 .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
18169 .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
18170 .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
18171 .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; }
18172 .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:stretch; flex: 1; min-height: 0; }
18173 .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; }
18174 .side-stack::-webkit-scrollbar { display: none; }
18175 .step-nav { padding: 20px 16px; }
18176 .step-nav h3 { margin: 6px 4px 14px; font-size: 16px; font-weight: 850; letter-spacing: -0.01em; }
18177 .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; }
18178 .step-button:hover { background: var(--surface-2); }
18179 .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); }
18180 .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; }
18181 .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
18182 .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
18183 .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
18184 .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); }
18185 .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
18186 .step-nav-sum-row:last-child { border-bottom:none; }
18187 .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
18188 .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; }
18189 .step-steps-divider { height:1px; background:var(--line); margin: 12px 4px; }
18190 .quick-scan-divider { height:1px; background:var(--line); margin: 12px 4px; }
18191 .quick-scan-section { padding: 10px 4px 14px; }
18192 .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
18193 .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; }
18194 .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
18195 .quick-scan-btn:active { transform:translateY(0); }
18196 .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
18197 .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
18198 .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
18199 @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);} }
18200 @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
18201 .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
18202 .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
18203 .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
18204 .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
18205 .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
18206 .step-button.done .step-check { opacity:1; }
18207 .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
18208 .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; }
18209 .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; }
18210 .sidebar-scroll-divider { height:1px; background:var(--line); margin: 12px 4px; }
18211 .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; }
18212 .sidebar-scroll-btn:hover { background:var(--surface-3); border-color:var(--line-strong); color:var(--text); text-decoration:none; }
18213 .sidebar-scroll-btn svg { width:12px; height:12px; stroke:currentColor; fill:none; stroke-width:2.5; flex-shrink:0; }
18214 .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; }
18215 body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
18216 .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
18217 .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
18218 .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
18219 .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
18220 .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
18221 .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
18222 .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
18223 .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
18224 .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
18225 .card-body { padding: 22px; }
18226 .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
18227 .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
18228 @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
18229 .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
18230 .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
18231 .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
18232 .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
18233 .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
18234 .field { min-width:0; }
18235 label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
18236 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; }
18237 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); }
18238 input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
18239 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); }
18240 textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
18241 textarea.glob-textarea { font-size: 13px; padding: 10px 12px; }
18242 .glob-label-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:6px; min-height:28px; }
18243 .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
18244 .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; }
18245 .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
18246 .path-history-badge.new { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
18247 .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
18248 body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
18249 .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
18250 .input-group.compact { grid-template-columns: 1fr auto auto; }
18251 .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
18252 .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)); }
18253 .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
18254 .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
18255 .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
18256 .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
18257 .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; }
18258 .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
18259 .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; }
18260 .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); }
18261 .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
18262 .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
18263 #browse-path { min-height: 38px; font-size: 13px; padding: 0 18px; }
18264 #use-sample-path { min-height: 38px; font-size: 13px; padding: 0 13px; }
18265 .scope-legend-badges { display:flex; flex:1; align-items:center; justify-content:space-evenly; gap:6px; min-width:0; flex-wrap:nowrap; }
18266 .scope-legend-row .badge { flex:0 0 auto; font-size: 11px; min-height: 24px; padding: 0 10px; white-space: nowrap; }
18267 @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; } }
18268 button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
18269 button.secondary { background: var(--surface); }
18270 button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
18271 button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
18272 button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
18273 button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
18274 .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); }
18275 .section + .wizard-actions { border-top: none; padding-top: 0; }
18276 .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; align-items:center; }
18277 .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; }
18278 .default-path-overlay.open { opacity: 1; pointer-events: auto; }
18279 .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; }
18280 .default-path-overlay.open .default-path-modal { transform: translateY(0); }
18281 .default-path-modal h3 { margin: 0 0 15px; font-size: 22px; color: var(--text); display: flex; align-items: center; gap: 12px; }
18282 .default-path-modal h3 svg { width: 26px; height: 26px; flex-shrink: 0; color: var(--accent); }
18283 .default-path-modal p { margin: 0 0 11px; font-size: 12px; line-height: 1.6; color: var(--muted); }
18284 .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); }
18285 body.dark-theme .default-path-modal p code { background: rgba(255,255,255,0.10); }
18286 .default-path-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 24px; }
18287 .default-path-actions button { font-size: 10.5px; padding: 6px 13px; border-radius: 8px; }
18288 .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
18289 .field-help-grid.coupled-help { margin-top: 12px; }
18290 .field-help-grid.preset-grid { align-items: start; }
18291 .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
18292 .preset-inline-row .field { margin: 0; }
18293 .preset-inline-row .explainer-card { margin: 0; }
18294 .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
18295 .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
18296 .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
18297 .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
18298 .preset-kv-row > :last-child { flex:1; min-width:0; }
18299 .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
18300 .output-field-row .field { margin: 0; }
18301 .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; }
18302 .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
18303 .step3-subtitle { margin-bottom: 10px; max-width: none; }
18304 .counting-intro { margin-bottom: 8px; max-width: none; }
18305 .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; }
18306 .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
18307 .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
18308 .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; }
18309 .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; }
18310 .section-spacer-top { margin-top: 28px; }
18311 .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
18312 .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
18313 .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
18314 .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); }
18315 .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
18316 .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; }
18317 .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; }
18318 .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
18319 .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
18320 .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
18321 .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
18322 .lbl-opt { font-weight:400; font-size:12px; color:var(--muted); margin-left:4px; }
18323 .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; }
18324 .include-scope-badge.scope-all { background:rgba(42,104,70,0.1); border:1px solid rgba(42,104,70,0.25); color:#2a6846; }
18325 .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); }
18326 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; }
18327 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; }
18328 .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
18329 .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
18330 .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
18331 .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
18332 .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
18333 .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
18334 .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
18335 .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
18336 .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); }
18337 .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
18338 .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
18339 .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; }
18340 .docstring-example-inset .field-help-title { margin-bottom: 6px; }
18341 .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; }
18342 .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; }
18343 .always-tracked-tip-body { flex:1; min-width:0; }
18344 .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
18345 .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
18346 .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
18347 .always-tracked-metrics-row { display:grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap:6px 18px; margin:8px 0 0; }
18348 .always-tracked-metrics-row > div { font-size:13px; color:var(--muted); line-height:1.5; }
18349 .always-tracked-metrics-row strong { display:block; font-size:13px; color:var(--text); margin-bottom:2px; white-space:nowrap; }
18350 @media (max-width:900px) { .always-tracked-metrics-row { grid-template-columns: repeat(2,minmax(0,1fr)); } }
18351 .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
18352 .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
18353 .advanced-rule-description strong { color: var(--text); }
18354 .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
18355 .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
18356 .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
18357 .review-link:hover { text-decoration: underline; }
18358 .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
18359 .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
18360 .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
18361 .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
18362 .review-card h4 { margin: 0 0 8px; font-size: 17px; }
18363 .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
18364 .review-card ul { padding-left: 18px; margin: 0; }
18365 .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
18366 .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
18367 .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
18368 .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
18369 .review-card { min-height: 0; }
18370 .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
18371 .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
18372 .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
18373 .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
18374 .lang-overflow-chip { position:relative; cursor:default; }
18375 .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; }
18376 .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
18377 .git-inline-row { align-items:start; }
18378 .mixed-line-card { display:flex; flex-direction:column; }
18379 .preset-inline-row .toggle-card { justify-content: center; }
18380 .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
18381 .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
18382 .explorer-toolbar.compact { padding: 0; border-bottom: none; }
18383 .explorer-title { font-size: 18px; font-weight: 850; }
18384 .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
18385 .explorer-subtitle.wide { max-width: none; }
18386 .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
18387 .better-spacing { align-items:flex-start; justify-content:flex-end; }
18388 .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; }
18389 .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
18390 .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
18391 .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
18392 .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
18393 body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
18394 .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
18395 .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; }
18396 .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
18397 .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
18398 .scope-stat-button.supported { background: var(--success-bg); }
18399 .scope-stat-button.skipped { background: var(--warn-bg); }
18400 .scope-stat-button.unsupported { background: var(--danger-bg); }
18401 .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
18402 .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
18403 .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
18404 [data-tooltip] { position: relative; }
18405 [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); }
18406 [data-tooltip]:hover::after { display: block; }
18407 .scope-stat-button[data-tooltip] { cursor: pointer; }
18408 .badge[data-tooltip] { cursor: help; }
18409 .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
18410 .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
18411 .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
18412 .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; }
18413 .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; }
18414 code { display:inline-block; margin-top:0; padding:2px 7px; }
18415 .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
18416 .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
18417 .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
18418 .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
18419 .language-pill.muted-pill { color: var(--muted); }
18420 button.language-pill { appearance:none; cursor:pointer; }
18421 .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); }
18422 .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
18423 .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; }
18424 .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
18425 .file-explorer-search-row { margin-left: auto; }
18426 .explorer-filter-select { min-width: 170px; width: 170px; }
18427 .explorer-search { min-width: 300px; width: 300px; }
18428 .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); }
18429 .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; }
18430 .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
18431 .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
18432 .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
18433 .file-explorer-tree { max-height: 640px; overflow:auto; }
18434 .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); }
18435 .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
18436 body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
18437 .tree-row.hidden-by-filter { display:none !important; }
18438 .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
18439 .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; }
18440 .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; }
18441 .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
18442 .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
18443 .tree-node { display:inline-flex; align-items:center; min-width:0; }
18444 .tree-node-dir { color: var(--text); font-weight: 800; }
18445 .tree-node-supported { color: var(--success-text); }
18446 .tree-node-skipped { color: var(--warn-text); }
18447 .tree-node-unsupported { color: var(--danger-text); }
18448 .tree-node-more { color: var(--muted-2); font-style: italic; }
18449 .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
18450 .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
18451 .tree-status-cell { display:flex; justify-content:flex-start; }
18452 .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
18453 .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; }
18454 .preview-warning strong { display:block; font-size: 14px; margin-bottom: 4px; }
18455 .preview-warning p { margin: 0 0 10px; }
18456 .repo-pick-row { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-bottom: 10px; }
18457 .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; }
18458 .repo-pick:hover { background: var(--warn-text); color: var(--warn-bg); }
18459 .repo-pick-more { font-size: 12px; font-style: italic; opacity: 0.85; }
18460 .multi-repo-ack-label { display:flex; align-items:center; gap:8px; font-size: 12px; font-weight: 600; cursor: pointer; }
18461 .multi-repo-ack { width:15px; height:15px; accent-color: var(--warn-text); cursor: pointer; }
18462 .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; }
18463 .preview-loading { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
18464 .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; }
18465 @keyframes prevSpin { to { transform:rotate(360deg); } }
18466 .preview-gate-status { display:flex; align-items:center; gap:9px; font-size:13px; font-weight:600; color:var(--muted); margin-right:18px; }
18467 .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; }
18468 .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; }
18469 .preview-gate-info:hover { transform:scale(1.15); color:var(--nav); }
18470 .preview-gate-info svg { width:16px; height:16px; }
18471 .preview-panel-flash { animation:previewPanelFlash 1.4s ease; border-radius:12px; }
18472 @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); } }
18473 button.next-step.is-blocked { opacity:0.55; cursor:not-allowed; pointer-events:none; box-shadow:none; transform:none; }
18474 .preview-loading-text { flex:1; min-width:0; }
18475 .preview-loading-msg { font-size:13px; color:var(--text); font-weight:600; }
18476 .preview-loading-elapsed { font-size:11px; color:var(--muted); margin-top:2px; }
18477 .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
18478 .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
18479 .cov-scan-idle { display:none; }
18480 .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
18481 .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
18482 .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
18483 .cov-scan-title { font-weight:600; font-size:12.5px; }
18484 .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
18485 .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
18486 .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; }
18487 .cov-scan-use:hover { opacity:.75; }
18488 .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; }
18489 .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; }
18490 @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
18491 .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
18492 .cov-scan-scanning .cov-scan-title { color:var(--muted); }
18493 .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
18494 .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
18495 .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
18496 .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
18497 .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
18498 body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
18499 body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
18500 body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
18501 body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
18502 .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
18503 body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
18504 .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
18505 .cov-scan-hint .cov-scan-title { color:#7a5e00; }
18506 .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
18507 .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
18508 body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
18509 body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
18510 body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
18511 body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
18512 .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
18513 .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
18514 .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); }
18515 .loading.active { display:flex; }
18516 /* Lock page scroll while the analysis modal is open so the removed scrollbar
18517 gutter doesn't pull the centered card slightly left of true center. */
18518 body.modal-open { overflow: hidden; }
18519 .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; }
18520 /* Pulsating gradient sheen behind the modal content — replaces the old "Analysis running" pill */
18521 .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; }
18522 .loading-card.lc-pulsing::before { animation: lcCardPulse 3.6s ease-in-out infinite; }
18523 .loading-card > * { position:relative; z-index:1; }
18524 @keyframes lcCardPulse { 0%,100%{opacity:0.45;} 50%{opacity:1;} }
18525 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%); }
18526 .progress-bar { width:100%; height:9px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
18527 .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; }
18528 @keyframes pulseBar { 0% { transform: translateX(-130%); } 100% { transform: translateX(330%); } }
18529 .lc-title { font-size:1.44rem;font-weight:800;margin:0 0 6px; }
18530 .lc-sub { color:var(--muted);font-size:0.9rem;margin:0 0 18px; }
18531 .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; }
18532 .lc-metrics { display:flex;gap:10px;margin-bottom:16px; }
18533 .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:10px 14px;flex:1 1 0;min-width:0; }
18534 .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; }
18535 .lc-metric-value { font-size:1rem;font-weight:800;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
18536 .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; }
18537 .lc-steps { display:flex;align-items:center;gap:0;margin-bottom:18px; }
18538 .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; }
18539 .lc-step.active { color:var(--oxide,#d37a4c);background:rgba(211,122,76,0.1);border-color:rgba(211,122,76,0.32); }
18540 .lc-step.done { color:var(--muted);opacity:0.55; }
18541 .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; }
18542 .lc-step.active .lc-step-num { background:var(--oxide,#d37a4c);color:#fff; }
18543 .lc-step.done .lc-step-num { background:rgba(80,180,100,0.22);color:#2d8a45; }
18544 .lc-step-arrow { color:var(--line-strong,#ccc);font-size:16px;padding:0 8px;flex:0 0 auto;line-height:1; }
18545 .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; }
18546 .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; }
18547 .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
18548 .lc-err p { margin:0;font-size:12px;color:var(--muted); }
18549 .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; }
18550 .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
18551 .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
18552 .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; }
18553 .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
18554 .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
18555 .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; }
18556 .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
18557 .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
18558 .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
18559 .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
18560 body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
18561 body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
18562 .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; }
18563 .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
18564 body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
18565 .hidden { display:none !important; }
18566 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
18567 .site-footer a{color:var(--muted);}
18568 @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
18569 @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; } }
18570 .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;}
18571 @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));}}
18572 .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;}
18573 .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; }
18574 .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
18575 .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
18576 .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
18577 .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; }
18578 .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
18579 .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
18580 .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; }
18581 .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
18582 .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
18583 .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; }
18584 .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
18585 .path-info-row { display:flex; align-items:center; gap:6px; margin-top:6px; border-bottom:none; padding:0; }
18586 .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; }
18587 .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
18588 .info-icon-btn:hover { color:var(--text); }
18589 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); }
18590 body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
18591 body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
18592 .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;}
18593 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
18594 .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;}
18595 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
18596 #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);}
18597 #offline-file-banner.show{display:flex;}
18598 #offline-file-banner svg{flex-shrink:0;width:20px;height:20px;stroke:#f0b429;fill:none;stroke-width:2;}
18599 #offline-file-banner .ofb-text{flex:1;}
18600 #offline-file-banner .ofb-text a{color:#b35c00;font-weight:700;text-decoration:underline;}
18601 #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;}
18602 #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;}
18603 #offline-file-banner .ofb-dismiss:hover{background:#feefc3;}
18604 body.dark-theme #offline-file-banner{background:#2d2200;border-bottom-color:#c98a00;color:#e8c96a;}
18605 body.dark-theme #offline-file-banner svg{stroke:#c98a00;}
18606 body.dark-theme #offline-file-banner .ofb-text a{color:#f0c040;}
18607 body.dark-theme #offline-file-banner .ofb-code{background:rgba(255,255,255,0.08);}
18608 body.dark-theme #offline-file-banner .ofb-dismiss{border-color:#9a6a00;color:#e8c96a;}
18609 body.dark-theme #offline-file-banner .ofb-dismiss:hover{background:rgba(240,180,0,0.12);}
18610 </style>
18611</head>
18612<body id="page-top">
18613 <div id="offline-file-banner" role="alert">
18614 <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>
18615 <span class="ofb-text">
18616 Charts, images, and navigation require the oxide-sloc server.
18617 Start it with <span class="ofb-code">cargo run -p oxide-sloc</span> or <span class="ofb-code">bash run.sh</span>,
18618 then open this run at <a href="http://127.0.0.1:4317" target="_blank" rel="noopener">http://127.0.0.1:4317</a>.
18619 The metric tables below are fully readable without the server.
18620 </span>
18621 <button class="ofb-dismiss" id="ofb-dismiss-btn" type="button">Dismiss</button>
18622 </div>
18623 <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>
18624 <div class="background-watermarks" aria-hidden="true">
18625 <img src="/images/logo/logo-text.png" alt="" />
18626 <img src="/images/logo/logo-text.png" alt="" />
18627 <img src="/images/logo/logo-text.png" alt="" />
18628 <img src="/images/logo/logo-text.png" alt="" />
18629 <img src="/images/logo/logo-text.png" alt="" />
18630 <img src="/images/logo/logo-text.png" alt="" />
18631 <img src="/images/logo/logo-text.png" alt="" />
18632 <img src="/images/logo/logo-text.png" alt="" />
18633 <img src="/images/logo/logo-text.png" alt="" />
18634 <img src="/images/logo/logo-text.png" alt="" />
18635 <img src="/images/logo/logo-text.png" alt="" />
18636 <img src="/images/logo/logo-text.png" alt="" />
18637 <img src="/images/logo/logo-text.png" alt="" />
18638 <img src="/images/logo/logo-text.png" alt="" />
18639 </div>
18640 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
18641 <div class="top-nav">
18642 <div class="top-nav-inner">
18643 <a class="brand" href="/">
18644 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
18645 <div class="brand-copy">
18646 <div class="brand-title">OxideSLOC</div>
18647 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
18648 </div>
18649 </a>
18650 <div class="nav-project-slot">
18651 <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
18652 <span class="nav-project-label">Project</span>
18653 <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
18654 </div>
18655 </div>
18656 <div class="nav-status">
18657 <a class="nav-pill" href="/">Home</a>
18658 <div class="nav-dropdown">
18659 <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>
18660 <div class="nav-dropdown-menu">
18661 <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>
18662 </div>
18663 </div>
18664 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
18665 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
18666 <div class="nav-dropdown">
18667 <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>
18668 <div class="nav-dropdown-menu">
18669 <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>
18670 </div>
18671 </div>
18672 <div class="server-status-wrap" id="server-status-wrap">
18673 <div class="nav-pill server-online-pill" id="server-status-pill">
18674 <span class="status-dot" id="status-dot"></span>
18675 <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
18676 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
18677 </div>
18678 <div class="server-status-tip">
18679 {% if server_mode %}
18680 OxideSLOC is running in server mode — accessible on your LAN.
18681 {% else %}
18682 OxideSLOC is running locally — only accessible from this machine.
18683 {% endif %}
18684 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
18685 </div>
18686 </div>
18687 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
18688 <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>
18689 </button>
18690 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
18691 <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>
18692 <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>
18693 </button>
18694 </div>
18695 </div>
18696 </div>
18697
18698 <div class="loading" id="loading">
18699 <div class="loading-card" id="loading-card">
18700 <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
18701 <p class="lc-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
18702 <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>
18703 <div class="lc-steps" id="lc-steps">
18704 <div class="lc-step active" id="lc-step-1"><span class="lc-step-num">1</span>Discover</div>
18705 <div class="lc-step-arrow">›</div>
18706 <div class="lc-step" id="lc-step-2"><span class="lc-step-num">2</span>Analyze</div>
18707 <div class="lc-step-arrow">›</div>
18708 <div class="lc-step" id="lc-step-3"><span class="lc-step-num">3</span>Report</div>
18709 <div class="lc-step-arrow">›</div>
18710 <div class="lc-step" id="lc-step-4"><span class="lc-step-num">4</span>Done</div>
18711 </div>
18712 <div class="lc-stage-desc" id="lc-stage-desc">Initializing language analyzers and loading configuration…</div>
18713 <div class="lc-metrics" id="lc-metrics">
18714 <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
18715 <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
18716 <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>
18717 <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>
18718 </div>
18719 <div class="progress-bar" id="lc-progress-bar"><span></span></div>
18720 <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>
18721 <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>
18722 <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
18723 <div class="lc-actions hidden" id="lc-actions">
18724 <button class="primary" id="lc-dismiss" type="button">Try Again</button>
18725 <a href="/view-reports" class="lc-outline-btn">View Reports</a>
18726 </div>
18727 <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
18728 <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>
18729 Cancel scan
18730 </button>
18731 </div>
18732 </div>
18733
18734 <div class="page">
18735 <div class="workbench-strip">
18736 <div class="workbench-box wb-stats">
18737 <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
18738 <span class="wb-stats-title">Analysis session</span>
18739 </div>
18740 <div class="ws-left">
18741 <div class="ws-stat ws-stat-analyzers">
18742 <span class="ws-label">Analyzers</span>
18743 <span class="ws-value">
18744 <span class="ws-badge">60 languages</span>
18745 </span>
18746 <div class="ws-lang-tooltip">
18747 <div class="ws-lang-tooltip-hdr">60 supported languages</div>
18748 <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>
18749 <div class="ws-lang-grid">
18750 <span class="ws-lang-item">Assembly</span>
18751 <span class="ws-lang-item">C</span>
18752 <span class="ws-lang-item">C++</span>
18753 <span class="ws-lang-item">C#</span>
18754 <span class="ws-lang-item">Clojure</span>
18755 <span class="ws-lang-item">CSS</span>
18756 <span class="ws-lang-item">Dart</span>
18757 <span class="ws-lang-item">Dockerfile</span>
18758 <span class="ws-lang-item">Elixir</span>
18759 <span class="ws-lang-item">Erlang</span>
18760 <span class="ws-lang-item">F#</span>
18761 <span class="ws-lang-item">Go</span>
18762 <span class="ws-lang-item">Groovy</span>
18763 <span class="ws-lang-item">Haskell</span>
18764 <span class="ws-lang-item">HTML</span>
18765 <span class="ws-lang-item">Java</span>
18766 <span class="ws-lang-item">JavaScript</span>
18767 <span class="ws-lang-item">Julia</span>
18768 <span class="ws-lang-item">Kotlin</span>
18769 <span class="ws-lang-item">Lua</span>
18770 <span class="ws-lang-item">Makefile</span>
18771 <span class="ws-lang-item">Nim</span>
18772 <span class="ws-lang-item">Obj-C</span>
18773 <span class="ws-lang-item">OCaml</span>
18774 <span class="ws-lang-item">Perl</span>
18775 <span class="ws-lang-item">PHP</span>
18776 <span class="ws-lang-item">PowerShell</span>
18777 <span class="ws-lang-item">Python</span>
18778 <span class="ws-lang-item">R</span>
18779 <span class="ws-lang-item">Ruby</span>
18780 <span class="ws-lang-item">Rust</span>
18781 <span class="ws-lang-item">Scala</span>
18782 <span class="ws-lang-item">SCSS</span>
18783 <span class="ws-lang-item">Shell</span>
18784 <span class="ws-lang-item">SQL</span>
18785 <span class="ws-lang-item">Svelte</span>
18786 <span class="ws-lang-item">Swift</span>
18787 <span class="ws-lang-item">TypeScript</span>
18788 <span class="ws-lang-item">Vue</span>
18789 <span class="ws-lang-item">XML</span>
18790 <span class="ws-lang-item">Zig</span>
18791 <span class="ws-lang-item">Solidity</span>
18792 <span class="ws-lang-item">Protobuf</span>
18793 <span class="ws-lang-item">HCL</span>
18794 <span class="ws-lang-item">GraphQL</span>
18795 <span class="ws-lang-item">Ada</span>
18796 <span class="ws-lang-item">VHDL</span>
18797 <span class="ws-lang-item">Verilog</span>
18798 <span class="ws-lang-item">Tcl</span>
18799 <span class="ws-lang-item">Pascal</span>
18800 <span class="ws-lang-item">Visual Basic</span>
18801 <span class="ws-lang-item">Lisp</span>
18802 <span class="ws-lang-item">Fortran</span>
18803 <span class="ws-lang-item">Nix</span>
18804 <span class="ws-lang-item">Crystal</span>
18805 <span class="ws-lang-item">D</span>
18806 <span class="ws-lang-item">GLSL</span>
18807 <span class="ws-lang-item">CMake</span>
18808 <span class="ws-lang-item">Elm</span>
18809 <span class="ws-lang-item">Awk</span>
18810 </div>
18811 </div>
18812 </div>
18813 <div class="ws-divider"></div>
18814 <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>
18815 <div class="ws-divider"></div>
18816 <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.">
18817 <span class="ws-label">Output</span>
18818 <span class="ws-value">
18819 <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
18820 <span id="ws-output-root">project/sloc</span>
18821 </button>
18822 </span>
18823 </div>
18824 </div>
18825 </div>
18826 <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.">
18827 <div class="ws-history-label">Scan history</div>
18828 <div class="ws-history-inner">
18829 <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
18830 <div class="ws-mini-label">Scans</div>
18831 <div class="ws-mini-value" id="ws-scan-count">—</div>
18832 </div>
18833 <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
18834 <div class="ws-mini-label">Last Scan</div>
18835 <div class="ws-mini-value" id="ws-last-scan">—</div>
18836 </div>
18837 <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
18838 <div class="ws-mini-label">Branch</div>
18839 <div class="ws-mini-value" id="ws-branch">—</div>
18840 </div>
18841 </div>
18842 </div>
18843 </div>
18844
18845 <div class="layout">
18846 <aside class="side-stack">
18847 <section class="step-nav">
18848 <h3>Guided scan setup</h3>
18849 <a href="#page-top" class="sidebar-scroll-btn" aria-label="Scroll to top of page">
18850 <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="18 15 12 9 6 15"></polyline></svg>
18851 Top of page
18852 </a>
18853 <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>
18854 <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>
18855 <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>
18856 <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>
18857
18858 <div class="step-steps-divider"></div>
18859
18860 <div class="step-nav-info" id="step-nav-info">
18861 <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
18862 <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>
18863 </div>
18864
18865 <div class="step-nav-summary" id="sidebar-summary" style="display:none">
18866 <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>
18867 <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>
18868 <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>
18869 </div>
18870
18871 <div class="quick-scan-divider"></div>
18872 <div class="quick-scan-section">
18873 <div class="quick-scan-label">No customization needed?</div>
18874 <button type="button" id="quick-scan-btn" class="quick-scan-btn">
18875 <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>
18876 Quick Scan
18877 </button>
18878 <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2-4.</div>
18879 </div>
18880
18881 <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>
18882 <div class="sidebar-scroll-divider"></div>
18883 <a href="#page-bottom" class="sidebar-scroll-btn" aria-label="Skip to bottom of page">
18884 <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>
18885 Skip to bottom
18886 </a>
18887 </section>
18888
18889 </aside>
18890
18891 <section class="card">
18892 <div class="card-header">
18893 <div class="card-title-row">
18894 <div>
18895 <h1 class="card-title">Guided scan configuration</h1>
18896 <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
18897 </div>
18898 <div class="wizard-progress" aria-label="Scan setup progress">
18899 <div class="wizard-progress-top">
18900 <span class="wizard-progress-label">Setup progress</span>
18901 <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
18902 </div>
18903 <div class="wizard-progress-track">
18904 <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
18905 </div>
18906 </div>
18907 </div>
18908 </div>
18909 <div class="card-body">
18910 <form method="post" action="/analyze" id="analyze-form">
18911 <div class="wizard-step active" data-step="1">
18912 <div class="section">
18913 <div class="section-kicker">Step 1</div>
18914 <h2>Select project and preview scope</h2>
18915 <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
18916 <div class="field">
18917 <label for="path">Project path</label>
18918 {% if !git_repo.is_empty() %}
18919 <div class="git-source-banner">
18920 <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>
18921 Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
18922 <a href="/git-browser">← Back to Git Browser</a>
18923 </div>
18924 {% endif %}
18925 <div class="path-scope-grid">
18926 {% if !git_repo.is_empty() %}
18927 <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input" required style="grid-column:1/4;" />
18928 <input type="hidden" name="git_repo" value="{{ git_repo }}" />
18929 <input type="hidden" name="git_ref" value="{{ git_ref }}" />
18930 {% else %}
18931 <input id="path" name="path" type="text" value="testing/fixtures/basic" placeholder="/path/to/repository" required />
18932 <button type="button" class="mini-button oxide" id="browse-path">{% if server_mode %}Upload{% else %}Browse{% endif %}</button>
18933 <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
18934 {% endif %}
18935 <div class="path-scope-sep"></div>
18936 <div class="scope-legend-row">
18937 <span class="scope-legend-label">Scope legend:</span>
18938 <span class="scope-legend-badges">
18939 <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
18940 <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
18941 <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
18942 </span>
18943 </div>
18944 </div>
18945 {% if git_repo.is_empty() %}
18946 {% if server_mode %}
18947 <div id="upload-limit-tip" class="hint" style="margin-top:6px;font-size:11px;">
18948 ℹ️ Files are compressed and streamed — no fixed size limit.
18949 </div>
18950 {% endif %}
18951 <div class="path-info-row">
18952 <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
18953 <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>
18954 <span id="project-size-text">Project size: —</span>
18955 </button>
18956 </div>
18957 {% else %}
18958 <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
18959 {% endif %}
18960 <div id="path-history-badge" class="path-history-badge" style="display:none"></div>
18961 <div id="zero-files-warning" class="path-history-badge warning" style="display:none" role="alert"></div>
18962 </div>
18963
18964 <div class="scope-preview-divider" aria-hidden="true"></div>
18965
18966 <div id="preview-panel">
18967 <div class="preview-error">Loading preview...</div>
18968 </div>
18969 </div>
18970
18971 <div class="section" style="margin-top:14px;">
18972 <div class="preset-inline-row git-inline-row">
18973 <div class="toggle-card" style="margin:0;">
18974 <div class="field-help-title" style="margin-bottom:10px;">Git integration</div>
18975 <h4 style="margin:0 0 12px;font-size:16px;">Submodule breakdown</h4>
18976 <label class="checkbox">
18977 <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
18978 <div>
18979 <span>Detect and separate git submodules</span>
18980 <div class="hint" style="margin-top:4px;">Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
18981 </div>
18982 </label>
18983 </div>
18984 <div class="explainer-card prominent" style="margin:0;">
18985 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
18986 <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>
18987 <div class="code-sample" style="margin-top:10px;">[submodule "libs/core"]
18988 path = libs/core
18989 url = https://github.com/org/core.git
18990
18991[submodule "libs/ui"]
18992 path = libs/ui
18993 url = https://github.com/org/ui.git</div>
18994 </div>
18995 </div>
18996 </div>
18997
18998 <div class="section">
18999 <div class="field-grid">
19000 <div class="field">
19001 <div class="glob-label-row">
19002 <label for="include_globs" style="margin:0;flex-shrink:0;">Include globs <span class="lbl-opt">— optional</span></label>
19003 <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>
19004 </div>
19005 <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>
19006 <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>
19007 </div>
19008 <div class="field">
19009 <div class="glob-label-row">
19010 <label for="exclude_globs" style="margin:0;flex-shrink:0;">Exclude globs</label>
19011 </div>
19012 <textarea id="exclude_globs" name="exclude_globs" class="glob-textarea" placeholder="examples: vendor/** **/*.min.js"></textarea>
19013 <div id="quick-exclude-chips" class="quick-excl-row">
19014 <span class="quick-excl-label">Quick add:</span>
19015 <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
19016 <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
19017 <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
19018 <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
19019 <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
19020 <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>
19021 </div>
19022 <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>
19023 </div>
19024 </div>
19025 <div class="glob-guidance-grid">
19026 <div class="glob-guidance-card">
19027 <strong>How to read them</strong>
19028 <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>
19029 </div>
19030 <div class="glob-guidance-card">
19031 <strong>Common include examples</strong>
19032 <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>
19033 </div>
19034 <div class="glob-guidance-card">
19035 <strong>Common exclude examples</strong>
19036 <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
19037 </div>
19038 </div>
19039 </div>
19040
19041 <div class="section" style="margin-top:14px;">
19042 <div class="preset-inline-row git-inline-row">
19043 <div class="toggle-card" style="margin:0;">
19044 <div class="field-help-title" style="margin-bottom:10px;">Coverage</div>
19045 <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>
19046 <div class="field" style="margin:0;">
19047 <div class="input-group compact">
19048 <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
19049 <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
19050 </div>
19051 <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>
19052 <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
19053 </div>
19054 </div>
19055 <div class="explainer-card prominent" style="margin:0;">
19056 <div class="field-help-title" style="margin-bottom:8px;">What this does</div>
19057 <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>
19058 <div class="code-sample" style="margin-top:10px;font-size:12px;"># C / C++ — gcov + lcov (LCOV)
19059lcov --capture --directory . --output-file coverage/lcov.info
19060
19061# C / C++ — llvm-cov (LCOV)
19062llvm-profdata merge -sparse default.profraw -o default.profdata
19063llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
19064
19065# C# — coverlet (Cobertura XML)
19066dotnet test --collect:"XPlat Code Coverage"
19067
19068# Python — pytest-cov (Cobertura XML)
19069pytest --cov --cov-report=xml
19070
19071# Python — coverage.py native JSON
19072coverage run -m pytest && coverage json # writes coverage.json
19073
19074# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
19075./gradlew jacocoTestReport</div>
19076 </div>
19077 </div>
19078 </div>
19079
19080 <div class="wizard-actions">
19081 <div class="left"></div>
19082 <div class="right">
19083 <div id="preview-gate-status" class="preview-gate-status" aria-live="polite" style="display:none;">
19084 <span class="preview-gate-spinner" aria-hidden="true"></span>
19085 <span class="preview-gate-text">Scanning project scope…</span>
19086 <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">
19087 <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>
19088 </button>
19089 </div>
19090 <button type="button" class="secondary next-step" id="step1-next" data-next="2">Next: Counting rules</button>
19091 </div>
19092 </div>
19093 </div>
19094
19095 <div class="default-path-overlay" id="default-path-overlay" role="dialog" aria-modal="true" aria-labelledby="default-path-title">
19096 <div class="default-path-modal">
19097 <h3 id="default-path-title">
19098 <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>
19099 Proceed with the default sample test?
19100 </h3>
19101 <p>The <strong>Project path</strong> is still set to the bundled sample <code>testing/fixtures/basic</code></p>
19102 <p>You haven't selected your own project yet.</p>
19103 <p>Make sure to fill out the <strong>Project path</strong> with your repository and confirm it uploads successfully before scanning.</p>
19104 <div class="default-path-actions">
19105 <button type="button" class="secondary prev-step" id="default-path-cancel">Fill in project path</button>
19106 <button type="button" class="secondary next-step" id="default-path-proceed">Proceed with sample</button>
19107 </div>
19108 </div>
19109 </div>
19110
19111 <div class="wizard-step" data-step="2">
19112 <div class="section">
19113 <div class="section-kicker">Step 2</div>
19114 <h2>Choose counting behavior</h2>
19115 <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>
19116<div class="subsection-bar">Primary line classification</div>
19117 <div class="preset-kv-row">
19118 <div class="toggle-card mixed-line-card" style="margin:0;">
19119 <div class="field-help-title" style="margin-bottom:10px;">Primary line classification</div>
19120 <h4 style="margin:0 0 12px;font-size:16px;">Mixed-line policy</h4>
19121 <select id="mixed_line_policy" name="mixed_line_policy">
19122 <option value="code_only">Code only</option>
19123 <option value="code_and_comment">Code and comment</option>
19124 <option value="comment_only">Comment only</option>
19125 <option value="separate_mixed_category">Separate mixed category</option>
19126 </select>
19127 <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
19128 </div>
19129 <div class="explainer-card prominent" style="margin:0;">
19130 <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
19131 <div class="explainer-body" id="mixed-policy-description"></div>
19132 <div class="code-sample" id="mixed-policy-example"></div>
19133 </div>
19134 </div>
19135 </div>
19136
19137 <div class="subsection-bar">Additional scan rules</div>
19138 <div class="scan-rules-grid">
19139 <div class="preset-inline-row">
19140 <div class="toggle-card" style="margin:0;">
19141 <div class="field-help-title">Generated files</div>
19142 <h4 style="margin:6px 0 12px;font-size:16px;">Generated-file detection</h4>
19143 <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
19144 </div>
19145 <div class="explainer-card prominent" style="margin:0;">
19146 <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>
19147 <div class="code-sample" style="margin-top:10px;font-size:12px;"># generated_file_detection = "enabled"
19148# Files matching codegen patterns are excluded:
19149# *.generated.cs *.pb.go *.g.dart</div>
19150 </div>
19151 </div>
19152 <div class="preset-inline-row">
19153 <div class="toggle-card" style="margin:0;">
19154 <div class="field-help-title">Minified files</div>
19155 <h4 style="margin:6px 0 12px;font-size:16px;">Minified-file detection</h4>
19156 <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
19157 </div>
19158 <div class="explainer-card prominent" style="margin:0;">
19159 <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>
19160 <div class="code-sample" style="margin-top:10px;font-size:12px;"># minified_file_detection = "enabled"
19161# Heuristic: very long lines + low whitespace ratio
19162# jquery.min.js bundle.min.css → skipped</div>
19163 </div>
19164 </div>
19165 <div class="preset-inline-row">
19166 <div class="toggle-card" style="margin:0;">
19167 <div class="field-help-title">Vendor directories</div>
19168 <h4 style="margin:6px 0 12px;font-size:16px;">Vendor-directory detection</h4>
19169 <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
19170 </div>
19171 <div class="explainer-card prominent" style="margin:0;">
19172 <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>
19173 <div class="code-sample" style="margin-top:10px;font-size:12px;"># vendor_directory_detection = "enabled"
19174# Directories named vendor/ node_modules/ third_party/
19175# → entire subtree is excluded from totals</div>
19176 </div>
19177 </div>
19178 <div class="preset-inline-row">
19179 <div class="toggle-card" style="margin:0;">
19180 <div class="field-help-title">Lockfiles and manifests</div>
19181 <h4 style="margin:6px 0 12px;font-size:16px;">Include lockfiles</h4>
19182 <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
19183 </div>
19184 <div class="explainer-card prominent" style="margin:0;">
19185 <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>
19186 <div class="code-sample" style="margin-top:10px;font-size:12px;"># include_lockfiles = false (default)
19187# Files like package-lock.json Cargo.lock yarn.lock
19188# → skipped unless this is enabled</div>
19189 </div>
19190 </div>
19191 <div class="preset-inline-row">
19192 <div class="toggle-card" style="margin:0;">
19193 <div class="field-help-title">Binary handling</div>
19194 <h4 style="margin:6px 0 12px;font-size:16px;">Binary file behavior</h4>
19195 <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>
19196 </div>
19197 <div class="explainer-card prominent" style="margin:0;">
19198 <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>
19199 <div class="code-sample" style="margin-top:10px;font-size:12px;"># binary_file_behavior = "skip" (default)
19200# Detected via long lines + low whitespace heuristic
19201# .png .exe .so → skipped silently</div>
19202 </div>
19203 </div>
19204 <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
19205 <div class="toggle-card" style="margin:0;">
19206 <div class="field-help-title">Python docstrings</div>
19207 <h4 style="margin:6px 0 12px;font-size:16px;">Docstring counting</h4>
19208 <label class="checkbox">
19209 <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
19210 <span>Count as comment-style lines</span>
19211 </label>
19212 </div>
19213 <div class="explainer-card prominent" style="margin:0;">
19214 <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>
19215 <div class="code-sample" id="python-docstring-example" style="margin-top:10px;font-size:12px;white-space:pre;"></div>
19216 </div>
19217 </div>
19218 </div>
19219 <div class="subsection-bar">IEEE 1045-1992 counting</div>
19220 <div class="scan-rules-grid">
19221 <div class="preset-inline-row">
19222 <div class="toggle-card" style="margin:0;">
19223 <div class="field-help-title">Continuation lines</div>
19224 <h4 style="margin:6px 0 12px;font-size:16px;">Continuation-line policy</h4>
19225 <select name="continuation_line_policy" id="continuation_line_policy">
19226 <option value="each_physical_line" selected>Each physical line (default)</option>
19227 <option value="collapse_to_logical">Collapse to logical line</option>
19228 </select>
19229 </div>
19230 <div class="explainer-card prominent" style="margin:0;">
19231 <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>
19232 <div class="code-sample" style="margin-top:10px;font-size:12px;">#define MAX(a, b) \
19233 ((a) > (b) ? (a) : (b))
19234# each_physical_line → 2 SLOC
19235# collapse_to_logical → 1 SLOC</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">Block-comment blanks</div>
19241 <h4 style="margin:6px 0 12px;font-size:16px;">Blank lines in block comments</h4>
19242 <select name="blank_in_block_comment_policy" id="blank_in_block_comment_policy">
19243 <option value="count_as_comment" selected>Count as comment (default)</option>
19244 <option value="count_as_blank">Count as blank</option>
19245 </select>
19246 </div>
19247 <div class="explainer-card prominent" style="margin:0;">
19248 <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>
19249 <div class="code-sample" style="margin-top:10px;font-size:12px;">/*
19250 * Summary line
19251 * ← blank inside block comment
19252 * Detail line
19253 */
19254# count_as_comment → blank counts toward comments
19255# count_as_blank → blank counts toward blanks</div>
19256 </div>
19257 </div>
19258 <div class="preset-inline-row">
19259 <div class="toggle-card" style="margin:0;">
19260 <div class="field-help-title">Compiler directives</div>
19261 <h4 style="margin:6px 0 12px;font-size:16px;">Count compiler directives</h4>
19262 <select name="count_compiler_directives" id="count_compiler_directives">
19263 <option value="enabled" selected>Include in code SLOC (default)</option>
19264 <option value="disabled">Exclude from code SLOC</option>
19265 </select>
19266 </div>
19267 <div class="explainer-card prominent" style="margin:0;">
19268 <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>
19269 <div class="code-sample" style="margin-top:10px;font-size:12px;">#include <stdio.h> ← compiler directive
19270#define BUF 256 ← compiler directive
19271int main() { … } ← code
19272# enabled → 3 code SLOC
19273# disabled → 1 code SLOC + 2 directive lines</div>
19274 </div>
19275 </div>
19276 </div>
19277
19278 <div class="subsection-bar">Code Style Analysis</div>
19279 <div class="scan-rules-grid">
19280 <div class="preset-inline-row">
19281 <div class="toggle-card" style="margin:0;">
19282 <div class="field-help-title">Style analysis</div>
19283 <h4 style="margin:6px 0 12px;font-size:16px;">Enable style analysis</h4>
19284 <select name="style_analysis_enabled" id="style_analysis_enabled">
19285 <option value="enabled" selected>Enabled (default)</option>
19286 <option value="disabled">Disabled — skip style scoring</option>
19287 </select>
19288 </div>
19289 <div class="explainer-card prominent" style="margin:0;">
19290 <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>
19291 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_analysis_enabled = true (default)
19292# style_analysis_enabled = false (skip, faster scan)
19293# Disabling removes the Code Style section from the report.</div>
19294 </div>
19295 </div>
19296 <div class="preset-inline-row">
19297 <div class="toggle-card" style="margin:0;">
19298 <div class="field-help-title">Column-width threshold</div>
19299 <h4 style="margin:6px 0 12px;font-size:16px;">Line-length compliance column</h4>
19300 <select name="style_col_threshold" id="style_col_threshold">
19301 <option value="80" selected>80 columns (PEP 8, Google, gofmt)</option>
19302 <option value="100">100 columns (Uber Go, Google Java)</option>
19303 <option value="120">120 columns (Uber Go max, Kotlin)</option>
19304 </select>
19305 </div>
19306 <div class="explainer-card prominent" style="margin:0;">
19307 <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>
19308 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_col_threshold = 80 (PEP 8, Google, gofmt)
19309# style_col_threshold = 100 (Uber Go, Google Java)
19310# style_col_threshold = 120 (Uber Go max, Kotlin)
19311# Files where <= 5% of lines exceed the limit
19312# are counted as "N-col compliant" in the report.</div>
19313 </div>
19314 </div>
19315 <div class="preset-inline-row">
19316 <div class="toggle-card" style="margin:0;">
19317 <div class="field-help-title">Score alert threshold</div>
19318 <h4 style="margin:6px 0 12px;font-size:16px;">Low-score file alert</h4>
19319 <select name="style_score_threshold" id="style_score_threshold">
19320 <option value="0" selected>Off — no threshold (default)</option>
19321 <option value="40">40% — flag poorly styled files</option>
19322 <option value="50">50% — flag below-average files</option>
19323 <option value="60">60% — flag below-good files</option>
19324 <option value="70">70% — flag below-strong files</option>
19325 </select>
19326 </div>
19327 <div class="explainer-card prominent" style="margin:0;">
19328 <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>
19329 <div class="code-sample" style="margin-top:10px;font-size:12px;"># style_score_threshold = 0 (off, default)
19330# style_score_threshold = 50 (flag files < 50%)
19331# Low-scoring files get a red left-border in the
19332# per-file style breakdown table.</div>
19333 </div>
19334 </div>
19335 </div>
19336
19337 <div class="always-tracked-tip">
19338 <div class="always-tracked-tip-icon">ℹ</div>
19339 <div class="always-tracked-tip-body">
19340 <div class="field-help-title">Always tracked — not configurable · What these settings change</div>
19341 <h4>Comment and blank-line basics & Lines on the boundary</h4>
19342 <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>
19343 </div>
19344 </div>
19345
19346 <div class="subsection-bar">Advanced Metrics</div>
19347 <div class="scan-rules-grid">
19348 <div class="preset-inline-row">
19349 <div class="toggle-card" style="margin:0;">
19350 <div class="field-help-title">COCOMO mode</div>
19351 <h4 style="margin:6px 0 12px;font-size:16px;">Cost estimation model</h4>
19352 <select name="cocomo_mode" id="cocomo_mode">
19353 <option value="organic" selected>Organic — small team, familiar domain (default)</option>
19354 <option value="semi_detached">Semi-detached — mixed constraints</option>
19355 <option value="embedded">Embedded — tight hardware/OS constraints</option>
19356 </select>
19357 </div>
19358 <div class="explainer-card prominent" style="margin:0;">
19359 <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>
19360 <div class="code-sample" style="margin-top:10px;font-size:12px;"># Organic: Effort = 2.4 × KSLOC^1.05
19361# Semi-detached: Effort = 3.0 × KSLOC^1.12
19362# Embedded: Effort = 3.6 × KSLOC^1.20
19363# All modes: Schedule = 2.5 × Effort^d</div>
19364 </div>
19365 </div>
19366 <div class="preset-inline-row">
19367 <div class="toggle-card" style="margin:0;">
19368 <div class="field-help-title">Complexity alert</div>
19369 <h4 style="margin:6px 0 12px;font-size:16px;">Complexity score alert threshold</h4>
19370 <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;" />
19371 </div>
19372 <div class="explainer-card prominent" style="margin:0;">
19373 <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>
19374 <div class="code-sample" style="margin-top:10px;font-size:12px;"># 0 or blank = no alert (default)
19375# 50 = flag any file with > 50 branch points
19376# 100 = flag any file with > 100 branch points
19377# Files above the threshold are highlighted
19378# in the result page metric strip.</div>
19379 </div>
19380 </div>
19381 <div class="preset-inline-row">
19382 <div class="toggle-card" style="margin:0;">
19383 <div class="field-help-title">Git hotspots</div>
19384 <h4 style="margin:6px 0 12px;font-size:16px;">Activity window (days)</h4>
19385 <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;" />
19386 </div>
19387 <div class="explainer-card prominent" style="margin:0;">
19388 <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>
19389 <div class="code-sample" style="margin-top:10px;font-size:12px;"># 90 = last quarter (default)
19390# 30 = last month of activity
19391# 365 = last year
19392# 0 = disable the hotspots table
19393# Adds Commits + Last-changed columns to CSV.</div>
19394 </div>
19395 </div>
19396 <div class="preset-inline-row">
19397 <div class="toggle-card" style="margin:0;">
19398 <div class="field-help-title">Duplicate handling</div>
19399 <h4 style="margin:6px 0 12px;font-size:16px;">Duplicate file detection</h4>
19400 <select name="exclude_duplicates" id="exclude_duplicates">
19401 <option value="disabled" selected>Detect and report only (default)</option>
19402 <option value="enabled">Detect and exclude from SLOC totals</option>
19403 </select>
19404 </div>
19405 <div class="explainer-card prominent" style="margin:0;">
19406 <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>
19407 <div class="code-sample" style="margin-top:10px;font-size:12px;"># A repo with 3 identical config files:
19408# detect only → all 3 counted in SLOC
19409# exclude dupes → 1 counted, 2 excluded
19410# Duplicate groups chip always shows the count.</div>
19411 </div>
19412 </div>
19413 <div class="always-tracked-tip" style="margin:8px 0 0;">
19414 <div class="always-tracked-tip-icon">ℹ</div>
19415 <div class="always-tracked-tip-body">
19416 <div class="field-help-title">Always computed — every scan produces these automatically</div>
19417 <div class="always-tracked-metrics-row">
19418 <div><strong>Cyclomatic complexity</strong>Counts branch keywords per file.</div>
19419 <div><strong>Logical SLOC</strong>Executable statements — C-family, Python, Ruby, Shell & more.</div>
19420 <div><strong>ULOC & DRYness</strong>De-duplicates lines project-wide; DRYness % = ULOC ÷ Code Lines.</div>
19421 <div><strong>COCOMO I</strong>Converts total SLOC into effort, schedule & team-size estimates.</div>
19422 </div>
19423 <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>
19424 </div>
19425 </div>
19426 </div>
19427
19428 <div class="wizard-actions">
19429 <div class="left">
19430 <button type="button" class="secondary prev-step" data-prev="1">Back</button>
19431 </div>
19432 <div class="right">
19433 <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
19434 </div>
19435 </div>
19436 </div>
19437
19438 <div class="wizard-step" data-step="3">
19439 <div class="section">
19440 <div class="section-kicker">Step 3</div>
19441 <h2>Output and report identity</h2>
19442 <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>
19443 <div class="preset-kv-row">
19444 <div class="toggle-card" style="margin:0;">
19445 <div class="field-help-title" style="margin-bottom:10px;">Scan configuration</div>
19446 <h4 style="margin:0 0 12px;font-size:16px;">Scan preset</h4>
19447 <select id="scan_preset">
19448 <option value="balanced">Balanced local scan</option>
19449 <option value="code_focused">Code focused</option>
19450 <option value="comment_audit">Comment audit</option>
19451 <option value="deep_review">Deep review</option>
19452 </select>
19453 <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
19454 </div>
19455 <div class="explainer-card">
19456 <div class="field-help-title">Selected scan preset</div>
19457 <div class="explainer-body" id="scan-preset-description"></div>
19458 <div class="preset-summary-row" id="scan-preset-summary"></div>
19459 <div class="code-sample" id="scan-preset-example"></div>
19460 <div class="preset-note" id="scan-preset-note"></div>
19461 </div>
19462 </div>
19463 <hr class="step3-separator" />
19464 <div class="preset-kv-row">
19465 <div class="toggle-card" style="margin:0;">
19466 <div class="field-help-title" style="margin-bottom:10px;">Output configuration</div>
19467 <h4 style="margin:0 0 12px;font-size:16px;">Artifact preset</h4>
19468 <select id="artifact_preset">
19469 <option value="review">Review bundle</option>
19470 <option value="full">Full bundle</option>
19471 <option value="html_only">HTML only</option>
19472 <option value="machine">Machine bundle</option>
19473 </select>
19474 <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
19475 </div>
19476 <div class="explainer-card">
19477 <div class="field-help-title">Selected artifact preset</div>
19478 <div class="explainer-body" id="artifact-preset-description"></div>
19479 <div class="preset-summary-row" id="artifact-preset-summary"></div>
19480 <div class="code-sample" id="artifact-preset-example"></div>
19481 </div>
19482 </div>
19483 </div>
19484
19485 <div class="section section-spacer-top">
19486 <div class="output-field-row">
19487 <div class="field">
19488 <label for="output_dir">Output directory</label>
19489 {% if server_mode %}
19490 <div class="input-group compact">
19491 <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);" />
19492 </div>
19493 <div class="hint">Output path is managed by the server — each run stores artifacts in a unique timestamped subfolder automatically.</div>
19494 {% else %}
19495 <div class="input-group compact">
19496 <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
19497 <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
19498 <button type="button" class="mini-button" id="use-default-output">Use default</button>
19499 </div>
19500 <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
19501 {% endif %}
19502 </div>
19503 <div class="output-field-aside">
19504 <strong>Where reports land</strong>
19505 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.
19506 </div>
19507 </div>
19508 </div>
19509
19510 <div class="section section-spacer-top">
19511 <div class="output-field-row">
19512 <div class="field">
19513 <label for="report_title">Report title</label>
19514 <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
19515 <div class="hint">Appears in HTML and PDF output headers.</div>
19516 </div>
19517 <div class="output-field-aside">
19518 <strong>Shown in exported artifacts</strong>
19519 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.
19520 </div>
19521 </div>
19522 </div>
19523
19524 <div class="section section-spacer-top">
19525 <div class="output-field-row">
19526 <div class="field">
19527 <label for="report_header_footer">Report header / footer</label>
19528 <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
19529 <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>
19530 </div>
19531 <div class="output-field-aside">
19532 <strong>Page-level identification</strong>
19533 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.
19534 </div>
19535 </div>
19536 </div>
19537
19538 <div class="wizard-actions">
19539 <div class="left">
19540 <button type="button" class="secondary prev-step" data-prev="2">Back</button>
19541 </div>
19542 <div class="right">
19543 <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
19544 </div>
19545 </div>
19546 </div>
19547
19548 <div class="wizard-step" data-step="4">
19549 <div class="section">
19550 <div class="section-kicker">Step 4</div>
19551 <h2>Review selections and run</h2>
19552 <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
19553 <div class="review-grid">
19554 <div class="review-card highlight">
19555 <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>
19556 <ul id="review-scan-summary"></ul>
19557 </div>
19558 <div class="review-card highlight">
19559 <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>
19560 <ul id="review-count-summary"></ul>
19561 </div>
19562 <div class="review-card">
19563 <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>
19564 <ul id="review-artifact-summary"></ul>
19565 <ul id="review-output-summary" style="margin-top:6px;padding-left:18px;margin-bottom:0;"></ul>
19566 </div>
19567 <div class="review-card">
19568 <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>
19569 <ul id="review-preview-summary"></ul>
19570 </div>
19571 </div>
19572 </div>
19573
19574 <div class="wizard-actions">
19575 <div class="left">
19576 <button type="button" class="secondary prev-step" data-prev="3">Back</button>
19577 </div>
19578 <div class="right">
19579 <button type="submit" id="submit-button" class="primary">Run analysis</button>
19580 </div>
19581 </div>
19582 </div>
19583 {% if server_mode %}
19584 <input type="file" id="dir-upload-input" webkitdirectory multiple style="display:none" aria-hidden="true">
19585 <input type="file" id="cov-upload-input" accept=".info,.lcov,.xml,.json" style="display:none" aria-hidden="true">
19586 {% endif %}
19587 </form>
19588 </div>
19589 </section>
19590 </div>
19591 </div>
19592
19593 <script nonce="{{ csp_nonce }}">
19594 (function () {
19595 function startScanPhase() {
19596 var phaseEl = document.getElementById("scan-phase");
19597 if (!phaseEl) return;
19598 var phases = [
19599 "Discovering files...",
19600 "Decoding file encodings...",
19601 "Detecting languages...",
19602 "Analyzing source lines...",
19603 "Applying counting policies...",
19604 "Aggregating results...",
19605 "Rendering report..."
19606 ];
19607 var durations = [800, 600, 1200, 3000, 1000, 800, 600];
19608 var i = 0;
19609 function next() {
19610 phaseEl.style.opacity = "0";
19611 setTimeout(function () {
19612 phaseEl.textContent = phases[i];
19613 phaseEl.style.opacity = "0.85";
19614 var delay = durations[i] || 1800;
19615 i++;
19616 if (i < phases.length) { setTimeout(next, delay); }
19617 }, 200);
19618 }
19619 next();
19620 }
19621
19622 var form = document.getElementById("analyze-form");
19623 var loading = document.getElementById("loading");
19624 var submitButton = document.getElementById("submit-button");
19625 var pathInput = document.getElementById("path");
19626 var GIT_MODE = !!(pathInput && pathInput.readOnly);
19627 var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
19628 var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
19629 var outputDirInput = document.getElementById("output_dir");
19630 var reportTitleInput = document.getElementById("report_title");
19631 var previewPanel = document.getElementById("preview-panel");
19632 var refreshButton = document.getElementById("refresh-preview");
19633 var refreshPreviewInline = document.getElementById("refresh-preview-inline");
19634 var useSamplePath = document.getElementById("use-sample-path");
19635 var useDefaultOutput = document.getElementById("use-default-output");
19636 var browsePath = document.getElementById("browse-path");
19637 var browseOutputDir = document.getElementById("browse-output-dir");
19638 var browseCoverage = document.getElementById("browse-coverage");
19639 var coverageInput = document.getElementById("coverage_file");
19640 var covScanStatus = document.getElementById("cov-scan-status");
19641 var coverageSuggestTimer = null;
19642 var covAutoFilled = false;
19643 var SERVER_MODE = {% if server_mode %}true{% else %}false{% endif %};
19644
19645 // Scroll long path inputs to end on blur (replaces inline onblur="..." removed for CSP).
19646 (function() {
19647 var ids = ["path", "output_dir"];
19648 ids.forEach(function(id) {
19649 var el = document.getElementById(id);
19650 if (el) el.addEventListener("blur", function() { this.scrollLeft = this.scrollWidth; });
19651 });
19652 }());
19653 function fmtBytes(b) {
19654 b = Number(b) || 0;
19655 if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GB';
19656 if (b >= 1048576) return (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
19657 if (b >= 1024) return Math.round(b / 1024) + ' KB';
19658 return b + ' B';
19659 }
19660 var themeToggle = document.getElementById("theme-toggle");
19661
19662 function showBannerToast(msg, isError, opts) {
19663 opts = opts || {};
19664 var t = document.createElement('div');
19665 t.className = isError ? 'toast-error' : 'toast-success';
19666 var topPos = opts.top ? '80px' : null;
19667 t.style.cssText = 'position:fixed;' + (topPos ? 'top:' + topPos + ';' : 'bottom:24px;') +
19668 'left:50%;transform:translateX(-50%);z-index:9999;min-width:320px;max-width:560px;' +
19669 'box-shadow:0 8px 32px rgba(0,0,0,0.22);padding:14px 20px;border-radius:12px;' +
19670 'font-size:13px;font-weight:600;line-height:1.5;text-align:center;';
19671 if (opts.icon) {
19672 var inner = document.createElement('span');
19673 inner.innerHTML = opts.icon + ' ';
19674 t.appendChild(inner);
19675 }
19676 t.appendChild(document.createTextNode(msg));
19677 document.body.appendChild(t);
19678 setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5500);
19679 }
19680 var mixedLinePolicy = document.getElementById("mixed_line_policy");
19681 var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
19682 var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
19683 var scanPreset = document.getElementById("scan_preset");
19684 var artifactPreset = document.getElementById("artifact_preset");
19685 var includeGlobsInput = document.getElementById("include_globs");
19686 var excludeGlobsInput = document.getElementById("exclude_globs");
19687
19688 // Include globs scope badge — updates reactively as the user types.
19689 (function() {
19690 var badge = document.getElementById("include-scope-badge");
19691 if (!badge || !includeGlobsInput) return;
19692 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> ';
19693 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> ';
19694 function update() {
19695 var val = includeGlobsInput.value.trim();
19696 if (!val) {
19697 badge.className = "include-scope-badge scope-all";
19698 badge.innerHTML = iconCheck + "All files eligible \u2014 no include filter active";
19699 } else {
19700 var count = val.split(/[\n,]+/).filter(function(s) { return s.trim(); }).length;
19701 badge.className = "include-scope-badge scope-narrow";
19702 badge.innerHTML = iconFilter + "Scoped to " + count + " pattern" + (count === 1 ? "" : "s") + " \u2014 only matching files will be included";
19703 }
19704 }
19705 includeGlobsInput.addEventListener("input", update);
19706 update();
19707 }());
19708
19709 // Quick-exclude chips — append pattern to exclude_globs textarea.
19710 document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
19711 chip.addEventListener("click", function() {
19712 var pattern = chip.getAttribute("data-pattern") || "";
19713 if (!pattern || !excludeGlobsInput) return;
19714 var current = excludeGlobsInput.value.trim();
19715 // For the "skip all" chip, replace any existing dep patterns cleanly.
19716 var patterns = pattern.split("\n");
19717 var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
19718 var added = false;
19719 patterns.forEach(function(p) {
19720 p = p.trim();
19721 if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
19722 });
19723 if (added) {
19724 excludeGlobsInput.value = lines.join("\n");
19725 excludeGlobsInput.dispatchEvent(new Event("input"));
19726 }
19727 chip.classList.add("active");
19728 });
19729 });
19730
19731 var liveReportTitle = document.getElementById("live-report-title");
19732 var navProjectPill = document.getElementById("nav-project-pill");
19733 var navProjectTitle = document.getElementById("nav-project-title");
19734 var reportTitlePreview = null;
19735 var wizardProgressFill = document.getElementById("wizard-progress-fill");
19736 var wizardProgressValue = document.getElementById("wizard-progress-value");
19737 var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
19738 var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
19739 var reportTitleTouched = false;
19740 var currentStep = 1;
19741 var previewTimer = null;
19742 var _previewGen = 0;
19743 // True while the scope preview (local) / project upload (server mode) is in
19744 // flight. The step 1 -> 2 "Next" button is blocked until it settles so the
19745 // user can't advance past a project whose scope/upload isn't ready yet.
19746 var previewLoading = false;
19747 // Set when the current preview reports multiple independent git repos under
19748 // the selected root. Advancing past step 1 is blocked until the user ticks
19749 // the acknowledgement checkbox (or re-selects a single repository).
19750 var multiRepoBlocked = false;
19751 function step1ForwardBlocked() {
19752 return previewLoading || multiRepoBlocked;
19753 }
19754 function refreshStep1Gate() {
19755 var nextBtn = document.getElementById("step1-next");
19756 if (nextBtn) {
19757 var blocked = step1ForwardBlocked();
19758 nextBtn.classList.toggle("is-blocked", blocked);
19759 nextBtn.setAttribute("aria-disabled", blocked ? "true" : "false");
19760 }
19761 }
19762 function setPreviewLoading(loading) {
19763 previewLoading = !!loading;
19764 var gate = document.getElementById("preview-gate-status");
19765 refreshStep1Gate();
19766 if (gate) {
19767 var txt = gate.querySelector(".preview-gate-text");
19768 if (txt) txt.textContent = SERVER_MODE
19769 ? "Uploading & scanning project…"
19770 : "Scanning project scope…";
19771 gate.style.display = previewLoading ? "flex" : "none";
19772 }
19773 }
19774 // Info button on the gate: scroll up to the live scope preview so the user
19775 // can see exactly what is being scanned (elapsed time + rotating status).
19776 var previewGateInfo = document.getElementById("preview-gate-info");
19777 if (previewGateInfo) {
19778 previewGateInfo.addEventListener("click", function () {
19779 var target = document.getElementById("preview-panel");
19780 if (!target) return;
19781 target.scrollIntoView({ behavior: "smooth", block: "center" });
19782 target.classList.add("preview-panel-flash");
19783 setTimeout(function () { target.classList.remove("preview-panel-flash"); }, 1400);
19784 });
19785 }
19786 var quickScanBtn = document.getElementById("quick-scan-btn");
19787
19788 function dismissAnalysisModal() {
19789 if (loading) loading.classList.remove("active");
19790 document.body.classList.remove("modal-open");
19791 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
19792 var el = document.getElementById(id);
19793 if (el) el.classList.add("hidden");
19794 });
19795 var cancelBtn = document.getElementById("lc-cancel-btn");
19796 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "\u2715 Cancel scan"; }
19797 var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
19798 var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
19799 var sd = document.getElementById("lc-stage-desc"); if (sd) sd.textContent = "Initializing language analyzers and loading configuration\u2026";
19800 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");}
19801 var rsc=document.getElementById("lc-speed-card");if(rsc)rsc.classList.add("hidden");
19802 var rcard = document.getElementById("loading-card"); if (rcard) rcard.classList.add("lc-pulsing");
19803 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
19804 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
19805 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19806 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19807 }
19808
19809 var lcDismissBtn = document.getElementById("lc-dismiss");
19810 if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
19811
19812 // When the browser restores this page from bfcache (Back button after navigating to results),
19813 // the loading overlay would still be showing its active state. Dismiss it immediately.
19814 window.addEventListener("pageshow", function(e) {
19815 if (e.persisted) { dismissAnalysisModal(); }
19816 });
19817
19818 function startAsyncAnalysis(formData) {
19819 var gitRepo = (formData.get("git_repo") || "").toString();
19820 var gitRef = (formData.get("git_ref") || "").toString();
19821 var pathVal = (gitRepo || (formData.get("path") || "")).toString();
19822 var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
19823
19824 var pathEl = document.getElementById("lc-path-text");
19825 if (pathEl) pathEl.textContent = displayPath;
19826
19827 ["lc-err","lc-warn","lc-actions","lc-cancelled"].forEach(function(id) {
19828 var el = document.getElementById(id);
19829 if (el) el.classList.add("hidden");
19830 });
19831 var cancelBtn = document.getElementById("lc-cancel-btn");
19832 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
19833 var startCard = document.getElementById("loading-card"); if (startCard) startCard.classList.add("lc-pulsing");
19834 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
19835 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
19836 var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
19837 var phase0 = document.getElementById("lc-phase"); if (phase0) phase0.textContent = "Starting";
19838 var sd0 = document.getElementById("lc-stage-desc"); if (sd0) sd0.textContent = "Initializing language analyzers and loading configuration\u2026";
19839 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");}
19840 var sc0=document.getElementById("lc-speed-card");if(sc0)sc0.classList.add("hidden");
19841
19842 if (loading) loading.classList.add("active");
19843 document.body.classList.add("modal-open");
19844
19845 var startTime = Date.now();
19846 var elapsedTimer = setInterval(function() {
19847 var s = Math.floor((Date.now() - startTime) / 1000);
19848 var el = document.getElementById("lc-elapsed");
19849 if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
19850 }, 1000);
19851
19852 var warnShown = false, pollRetries = 0, activeWaitId = null, lastFd = 0, lastFdTime = Date.now();
19853
19854 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();}
19855
19856 var PHASE_DESC = {
19857 'Starting': 'Initializing language analyzers and loading configuration\u2026',
19858 'Scanning files': 'Walking the directory tree, applying scope filters, and reading file bytes\u2026',
19859 'Running': 'Running the lexical state machine across all discovered source files\u2026',
19860 'Writing reports': 'Rendering the HTML report and saving JSON artifacts to disk\u2026',
19861 'Done': 'Analysis complete \u2014 loading your results\u2026',
19862 'Failed': 'Analysis encountered an error. Check the path and permissions, then try again.'
19863 };
19864 var PHASE_STEP = {'Starting':1,'Scanning files':1,'Running':2,'Writing reports':3,'Done':4};
19865 function lcSetPhase(txt) {
19866 var el = document.getElementById("lc-phase"); if (el) el.textContent = txt;
19867 var desc = document.getElementById("lc-stage-desc");
19868 if (desc) desc.textContent = PHASE_DESC[txt] || (txt + '\u2026');
19869 var step = PHASE_STEP[txt] || 1;
19870 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");}
19871 }
19872
19873 function lcShowCancelled() {
19874 clearInterval(elapsedTimer);
19875 var ccard = document.getElementById("loading-card"); if (ccard) ccard.classList.remove("lc-pulsing");
19876 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
19877 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
19878 var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
19879 var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
19880 var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
19881 var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
19882 var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
19883 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19884 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19885 }
19886
19887 var lcCancelBtn = document.getElementById("lc-cancel-btn");
19888 if (lcCancelBtn) {
19889 lcCancelBtn.onclick = function() {
19890 if (!activeWaitId) { dismissAnalysisModal(); return; }
19891 lcCancelBtn.disabled = true;
19892 lcCancelBtn.textContent = "Cancelling\u2026";
19893 fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
19894 .then(function() { lcShowCancelled(); })
19895 .catch(function() { lcShowCancelled(); });
19896 };
19897 }
19898
19899 function lcShowError(msg) {
19900 clearInterval(elapsedTimer);
19901 var ecard = document.getElementById("loading-card"); if (ecard) ecard.classList.remove("lc-pulsing");
19902 lcSetPhase("Failed");
19903 var msgEl = document.getElementById("lc-err-msg");
19904 if (msgEl) msgEl.textContent = msg || "Analysis failed.";
19905 var errEl = document.getElementById("lc-err");
19906 var actEl = document.getElementById("lc-actions");
19907 if (errEl) errEl.classList.remove("hidden");
19908 if (actEl) actEl.classList.remove("hidden");
19909 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
19910 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
19911 }
19912
19913 function lcPoll(waitId) {
19914 fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
19915 .then(function(r) {
19916 if (!r.ok) throw new Error("HTTP " + r.status);
19917 return r.json();
19918 })
19919 .then(function(data) {
19920 pollRetries = 0;
19921 if (data.state === "complete") {
19922 clearInterval(elapsedTimer);
19923 lcSetPhase("Done");
19924 window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
19925 } else if (data.state === "failed") {
19926 lcShowError(data.message);
19927 } else if (data.state === "cancelled") {
19928 lcShowCancelled();
19929 } else {
19930 var s = Math.floor((Date.now() - startTime) / 1000);
19931 if (s > 90 && !warnShown) {
19932 warnShown = true;
19933 var w = document.getElementById("lc-warn");
19934 if (w) w.classList.remove("hidden");
19935 }
19936 lcSetPhase(data.phase || "Running");
19937 var fd = data.files_done || 0, ft = data.files_total || 0;
19938 if (ft > 0) {
19939 var card = document.getElementById("lc-files-card");
19940 if (card) card.classList.remove("hidden");
19941 var el = document.getElementById("lc-files");
19942 if (el) el.textContent = fmt(fd) + " / " + fmt(ft);
19943 var now = Date.now();
19944 var fdelta = fd - lastFd, tdelta = (now - lastFdTime) / 1000;
19945 if (fdelta > 0 && tdelta > 0.4) {
19946 var fps = Math.round(fdelta / tdelta);
19947 var spEl = document.getElementById("lc-speed"); if (spEl) spEl.textContent = fmt(fps);
19948 var spCard = document.getElementById("lc-speed-card"); if (spCard) spCard.classList.remove("hidden");
19949 }
19950 lastFd = fd; lastFdTime = now;
19951 }
19952 setTimeout(function() { lcPoll(waitId); }, 1500);
19953 }
19954 })
19955 .catch(function() {
19956 pollRetries++;
19957 if (pollRetries >= 5) {
19958 lcShowError("Lost connection to server. Reload to check status.");
19959 } else {
19960 setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
19961 }
19962 });
19963 }
19964
19965 var params = new URLSearchParams(formData);
19966 fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
19967 .then(function(r) {
19968 var waitId = r.headers.get("x-wait-id");
19969 if (!waitId) { window.location.href = "/scan"; return; }
19970 activeWaitId = waitId;
19971 setTimeout(function() { lcPoll(waitId); }, 1500);
19972 })
19973 .catch(function(err) {
19974 lcShowError("Could not reach server: " + (err.message || err));
19975 });
19976 }
19977
19978 if (quickScanBtn) {
19979 quickScanBtn.addEventListener("click", function () {
19980 var pathVal = pathInput ? pathInput.value.trim() : "";
19981 if (!pathVal) {
19982 alert("Please enter or browse to a project path first.");
19983 return;
19984 }
19985 quickScanBtn.disabled = true;
19986 quickScanBtn.textContent = "Scanning...";
19987 if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
19988 startAsyncAnalysis(new FormData(form));
19989 });
19990 }
19991
19992 var mixedPolicyInfo = {
19993 code_only: {
19994 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.",
19995 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'
19996 },
19997 code_and_comment: {
19998 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.",
19999 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'
20000 },
20001 comment_only: {
20002 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.",
20003 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'
20004 },
20005 separate_mixed_category: {
20006 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.",
20007 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'
20008 }
20009 };
20010
20011 var scanPresetInfo = {
20012 balanced: {
20013 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.",
20014 chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
20015 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
20016 note: "Best when you want a stable local overview before making deeper adjustments.",
20017 apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
20018 },
20019 code_focused: {
20020 description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
20021 chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
20022 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
20023 note: "Use this when you mainly care about implementation size and want cleaner code totals.",
20024 apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
20025 },
20026 comment_audit: {
20027 description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
20028 chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
20029 example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
20030 note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
20031 apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
20032 },
20033 deep_review: {
20034 description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
20035 chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
20036 example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
20037 note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
20038 apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
20039 }
20040 };
20041
20042 var artifactPresetInfo = {
20043 review: {
20044 description: "HTML report for in-browser review. No PDF or data exports \u2014 fast and lightweight.",
20045 chips: ["HTML", "no PDF", "no JSON/CSV/XLSX"],
20046 example: "Ideal for a quick local review before sharing results."
20047 },
20048 full: {
20049 description: "All artifacts: HTML, PDF, JSON, CSV, and XLSX. Best for handoff packages or archiving.",
20050 chips: ["HTML", "PDF", "JSON", "CSV", "XLSX"],
20051 example: "Use when producing a deliverable or storing a snapshot for future comparison."
20052 },
20053 html_only: {
20054 description: "Standalone HTML report only. No PDF generation, no data files.",
20055 chips: ["HTML only"],
20056 example: "Fastest option when you only need to open the report in a browser."
20057 },
20058 machine: {
20059 description: "JSON and CSV data files only \u2014 no HTML or PDF. Designed for CI pipelines and automation.",
20060 chips: ["JSON", "CSV", "no HTML", "no PDF"],
20061 example: "Use in CI to capture metrics without generating visual reports."
20062 }
20063 };
20064
20065 function applyArtifactPreset() {
20066 var info = artifactPresetInfo[artifactPreset ? artifactPreset.value : "review"];
20067 if (!info) return;
20068 var descEl = document.getElementById("artifact-preset-description");
20069 var exampleEl = document.getElementById("artifact-preset-example");
20070 if (descEl) descEl.textContent = info.description;
20071 if (exampleEl) exampleEl.textContent = info.example;
20072 renderPresetChips("artifact-preset-summary", info.chips);
20073 }
20074
20075 function applyTheme(theme) {
20076 if (theme === "dark") document.body.classList.add("dark-theme");
20077 else document.body.classList.remove("dark-theme");
20078 }
20079
20080 function loadSavedTheme() {
20081 var saved = null;
20082 try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
20083 applyTheme(saved === "dark" ? "dark" : "light");
20084 }
20085
20086 function updateScrollProgress() {
20087 // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
20088 // Within each step, scroll position nudges the bar forward (max just below the next milestone).
20089 var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1-4 (index = step number)
20090 var stepEnd = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
20091 var step = Math.min(Math.max(currentStep, 1), 4);
20092 var base = stepBase[step];
20093 var end = stepEnd[step];
20094
20095 var scrollFrac = 0;
20096 var activePanel = document.querySelector(".wizard-step.active");
20097 if (activePanel) {
20098 var scrollTop = window.scrollY || window.pageYOffset || 0;
20099 var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
20100 var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
20101 var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
20102 var scrolled = scrollTop + viewH - panelTop;
20103 scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
20104 }
20105
20106 var percent = Math.round(base + (end - base) * scrollFrac);
20107 percent = Math.min(end, Math.max(base, percent));
20108 if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
20109 if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
20110 }
20111
20112 function updateWizardProgress() {
20113 updateScrollProgress();
20114 }
20115
20116 var stepDescriptions = [
20117 "Choose a project folder, apply scope filters, and preview which files will be counted.",
20118 "Configure how mixed code-plus-comment lines and docstrings are classified.",
20119 "Pick your output formats, scan preset, and where reports are saved.",
20120 "Review all settings and launch the analysis."
20121 ];
20122
20123 function updateStepNav(step) {
20124 var infoLabel = document.getElementById("step-nav-info-label");
20125 var infoDesc = document.getElementById("step-nav-info-desc");
20126 if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
20127 if (infoDesc) infoDesc.textContent = stepDescriptions[step - 1] || "";
20128 }
20129
20130 function updateSidebarSummary() {
20131 var sumPath = document.getElementById("sum-path");
20132 var sumPreset = document.getElementById("sum-preset");
20133 var sumOutput = document.getElementById("sum-output");
20134 var sidebarSummary = document.getElementById("sidebar-summary");
20135 var pathVal = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
20136 var presetVal = (scanPreset && scanPreset.value) ? scanPreset.value.replace(/_/g, " ") : "";
20137 var outputVal = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
20138 if (sumPath) sumPath.textContent = pathVal || "\u2014";
20139 if (sumPreset) sumPreset.textContent = presetVal || "\u2014";
20140 if (sumOutput) sumOutput.textContent = outputVal || "\u2014";
20141 if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
20142 }
20143
20144 function setStep(step, pushHistory) {
20145 currentStep = step;
20146 stepPanels.forEach(function (panel) {
20147 panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
20148 });
20149 stepButtons.forEach(function (button) {
20150 button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
20151 });
20152 var layoutEl = document.querySelector(".layout");
20153 if (layoutEl) layoutEl.setAttribute("data-active-step", step);
20154 updateWizardProgress();
20155 updateStepNav(step);
20156 stepButtons.forEach(function(btn) {
20157 var t = Number(btn.getAttribute("data-step-target"));
20158 btn.classList.toggle("done", t < step);
20159 });
20160 updateSidebarSummary();
20161
20162 if (pushHistory !== false) {
20163 try {
20164 history.pushState({ wizardStep: step }, "", "#step" + step);
20165 } catch (e) {}
20166 }
20167
20168 window.scrollTo({ top: 0, behavior: "instant" });
20169 }
20170
20171 window.addEventListener("popstate", function (e) {
20172 if (e.state && e.state.wizardStep) {
20173 setStep(e.state.wizardStep, false);
20174 } else {
20175 var hashMatch = location.hash.match(/^#step([1-4])$/);
20176 if (hashMatch) setStep(Number(hashMatch[1]), false);
20177 }
20178 });
20179
20180 function inferTitleFromPath(value) {
20181 if (!value) return "project";
20182 var cleaned = value.replace(/[\/\\]+$/, "");
20183 var parts = cleaned.split(/[\/\\]/).filter(Boolean);
20184 return parts.length ? parts[parts.length - 1] : value;
20185 }
20186
20187 function updateReportTitleFromPath() {
20188 var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
20189 if (!reportTitleTouched) {
20190 reportTitleInput.value = inferred;
20191 }
20192 var title = reportTitleInput.value || inferred;
20193 if (liveReportTitle) liveReportTitle.textContent = title;
20194 if (reportTitlePreview) reportTitlePreview.textContent = title;
20195 document.title = "OxideSLOC | " + title;
20196
20197 var projectPath = (pathInput.value || "").trim();
20198 if (navProjectPill && navProjectTitle) {
20199 if (projectPath.length > 0) {
20200 navProjectTitle.textContent = inferred;
20201 navProjectPill.classList.add("visible");
20202 } else {
20203 navProjectTitle.textContent = "";
20204 navProjectPill.classList.remove("visible");
20205 }
20206 }
20207 }
20208
20209 function updateMixedPolicyUI() {
20210 var key = mixedLinePolicy.value || "code_only";
20211 var info = mixedPolicyInfo[key];
20212 document.getElementById("mixed-policy-description").textContent = info.description;
20213 document.getElementById("mixed-policy-example").textContent = info.example;
20214 }
20215
20216 function updatePythonDocstringUI() {
20217 var checked = !!pythonDocstrings.checked;
20218 document.getElementById("python-docstring-example").textContent = checked
20219 ? 'def greet():\n """Greet the user.""" \u2190 comment\n print("hi")'
20220 : 'def greet():\n """Greet the user.""" \u2190 not counted\n print("hi")';
20221 document.getElementById("python-docstring-live-help").textContent = checked
20222 ? "Enabled: docstrings contribute to comment-style totals."
20223 : "Disabled: docstrings are not counted as comment content.";
20224 }
20225
20226 function renderPresetChips(targetId, chips) {
20227 var target = document.getElementById(targetId);
20228 if (!target) return;
20229 target.innerHTML = (chips || []).map(function (chip) {
20230 return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
20231 }).join('');
20232 }
20233
20234 function updatePresetDescriptions() {
20235 var scanInfo = scanPresetInfo[scanPreset.value];
20236 if (!scanInfo) return;
20237 document.getElementById("scan-preset-description").textContent = scanInfo.description;
20238 document.getElementById("scan-preset-example").textContent = scanInfo.example;
20239 document.getElementById("scan-preset-note").textContent = scanInfo.note;
20240 renderPresetChips("scan-preset-summary", scanInfo.chips);
20241 }
20242
20243 function applyScanPreset() {
20244 var info = scanPresetInfo[scanPreset.value];
20245 if (!info || !info.apply) return;
20246 mixedLinePolicy.value = info.apply.mixed;
20247 pythonDocstrings.checked = !!info.apply.docstrings;
20248 document.getElementById("generated_file_detection").value = info.apply.generated;
20249 document.getElementById("minified_file_detection").value = info.apply.minified;
20250 document.getElementById("vendor_directory_detection").value = info.apply.vendor;
20251 document.getElementById("include_lockfiles").value = info.apply.lockfiles;
20252 document.getElementById("binary_file_behavior").value = info.apply.binary;
20253 updateMixedPolicyUI();
20254 updatePythonDocstringUI();
20255 }
20256
20257 function updateReview() {
20258 var scanSummary = document.getElementById("review-scan-summary");
20259 var countSummary = document.getElementById("review-count-summary");
20260 var artifactSummary = document.getElementById("review-artifact-summary");
20261 var outputSummary = document.getElementById("review-output-summary");
20262 var previewSummary = document.getElementById("review-preview-summary");
20263 var readinessSummary = document.getElementById("review-readiness-summary");
20264 var includeText = document.getElementById("include_globs").value.trim();
20265 var excludeText = document.getElementById("exclude_globs").value.trim();
20266 var sidePathPreview = document.getElementById("side-path-preview");
20267 var sideOutputPreview = document.getElementById("side-output-preview");
20268 var sideTitlePreview = document.getElementById("side-title-preview");
20269
20270 if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
20271 if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
20272 if (sideTitlePreview) {
20273 var rt = document.getElementById("report_title");
20274 sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
20275 }
20276
20277 scanSummary.innerHTML = ""
20278 + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
20279 + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
20280 + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
20281
20282 countSummary.innerHTML = ""
20283 + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
20284 + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
20285 + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
20286 + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
20287 + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
20288 + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
20289 + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
20290 + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>";
20291
20292 artifactSummary.innerHTML = "<li>HTML, PDF, JSON, CSV, XLSX (always generated)</li>";
20293
20294 outputSummary.innerHTML = ""
20295 + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
20296 + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
20297
20298 if (previewSummary) {
20299 if (GIT_MODE) {
20300 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>';
20301 } else {
20302 var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
20303 var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
20304 var statMap = {};
20305 statButtons.forEach(function (button) {
20306 var valueNode = button.querySelector('.scope-stat-value');
20307 statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
20308 });
20309 previewSummary.innerHTML = ''
20310 + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
20311 + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
20312 + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
20313 + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
20314 + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
20315 + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
20316
20317 if (readinessSummary) {
20318 readinessSummary.innerHTML = ''
20319 + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
20320 + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
20321 + '<li>Ready to run: ' + (pathInput.value ? 'yes' : 'no') + '</li>';
20322 }
20323 } // end else (non-GIT_MODE)
20324 }
20325 }
20326
20327 function escapeHtml(value) {
20328 return String(value)
20329 .replace(/&/g, "&")
20330 .replace(/</g, "<")
20331 .replace(/>/g, ">")
20332 .replace(/"/g, """)
20333 .replace(/'/g, "'");
20334 }
20335
20336 function isPythonVisible() {
20337 return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
20338 }
20339
20340 function syncPythonVisibility() {
20341 var html = previewPanel.textContent || "";
20342 var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
20343 pythonWraps.forEach(function (node) {
20344 node.classList.toggle("hidden", !hasPython);
20345 });
20346 }
20347
20348 function attachPreviewInteractions() {
20349 // Multiple-repository caution banner: gate step 1 until acknowledged, and
20350 // let each listed repo be picked as the scan root with one click.
20351 var multiRepoBanner = previewPanel.querySelector(".preview-warning[data-multi-repo]");
20352 if (multiRepoBanner) {
20353 multiRepoBlocked = true;
20354 refreshStep1Gate();
20355 var ackBox = multiRepoBanner.querySelector(".multi-repo-ack");
20356 if (ackBox) {
20357 ackBox.addEventListener("change", function () {
20358 multiRepoBlocked = !ackBox.checked;
20359 refreshStep1Gate();
20360 });
20361 }
20362 var repoButtons = Array.prototype.slice.call(multiRepoBanner.querySelectorAll(".repo-pick"));
20363 repoButtons.forEach(function (btn) {
20364 btn.addEventListener("click", function () {
20365 var repoPath = btn.getAttribute("data-repo-path") || "";
20366 if (!repoPath || !pathInput) return;
20367 pathInput.value = repoPath;
20368 scrollInputToEnd(pathInput);
20369 updateReportTitleFromPath();
20370 autoSetOutputDir(repoPath);
20371 fetchProjectHistory(repoPath);
20372 loadPreview();
20373 updateReview();
20374 });
20375 });
20376 }
20377 var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
20378 var treeContainer = previewPanel.querySelector(".file-explorer-tree");
20379 var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
20380 var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
20381 var filterSelect = previewPanel.querySelector("#explorer-filter-select");
20382 var searchInput = previewPanel.querySelector("#explorer-search");
20383 var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
20384 var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
20385 var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
20386 var activeFilter = "all";
20387 var activeLanguage = "";
20388 var searchTerm = "";
20389 var currentSortKey = null;
20390 var currentSortOrder = "asc";
20391 var childRows = {};
20392
20393 rows.forEach(function (row) {
20394 var parentId = row.getAttribute("data-parent-id") || "";
20395 var rowId = row.getAttribute("data-row-id") || "";
20396 if (!childRows[parentId]) childRows[parentId] = [];
20397 childRows[parentId].push(rowId);
20398 });
20399
20400 function rowById(id) {
20401 return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
20402 }
20403
20404 function hasCollapsedAncestor(row) {
20405 var parentId = row.getAttribute("data-parent-id");
20406 while (parentId) {
20407 var parent = rowById(parentId);
20408 if (!parent) break;
20409 if (parent.getAttribute("data-expanded") === "false") return true;
20410 parentId = parent.getAttribute("data-parent-id");
20411 }
20412 return false;
20413 }
20414
20415 function updateToggleGlyph(row) {
20416 var toggle = row.querySelector(".tree-toggle");
20417 if (!toggle) return;
20418 toggle.textContent = row.getAttribute("data-expanded") === "false" ? "\u25b8" : "\u25be";
20419 }
20420
20421 function rowSortValue(row, key) {
20422 return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
20423 }
20424
20425 function updateSortButtons() {
20426 sortButtons.forEach(function (button) {
20427 var isActive = button.getAttribute("data-sort-key") === currentSortKey;
20428 var indicator = button.querySelector(".tree-sort-indicator");
20429 button.classList.toggle("active", isActive);
20430 button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
20431 if (indicator) {
20432 indicator.textContent = !isActive ? "\u2195" : (currentSortOrder === "asc" ? "\u2191" : "\u2193");
20433 }
20434 });
20435 }
20436
20437 function sortSiblingRows() {
20438 if (!treeContainer) {
20439 updateSortButtons();
20440 return;
20441 }
20442
20443 var rowMap = {};
20444 var childrenMap = {};
20445 rows.forEach(function (row) {
20446 var rowId = row.getAttribute("data-row-id");
20447 var parentId = row.getAttribute("data-parent-id") || "";
20448 rowMap[rowId] = row;
20449 if (!childrenMap[parentId]) childrenMap[parentId] = [];
20450 childrenMap[parentId].push(rowId);
20451 });
20452
20453 Object.keys(childrenMap).forEach(function (parentId) {
20454 if (!parentId) return;
20455 childrenMap[parentId].sort(function (a, b) {
20456 var rowA = rowMap[a];
20457 var rowB = rowMap[b];
20458 if (!currentSortKey) {
20459 return Number(a) - Number(b);
20460 }
20461 var valueA = rowSortValue(rowA, currentSortKey);
20462 var valueB = rowSortValue(rowB, currentSortKey);
20463 if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
20464 if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
20465 var fallbackA = rowSortValue(rowA, "name");
20466 var fallbackB = rowSortValue(rowB, "name");
20467 if (fallbackA < fallbackB) return -1;
20468 if (fallbackA > fallbackB) return 1;
20469 return Number(a) - Number(b);
20470 });
20471 });
20472
20473 var orderedIds = [];
20474 function pushChildren(parentId) {
20475 (childrenMap[parentId] || []).forEach(function (childId) {
20476 orderedIds.push(childId);
20477 pushChildren(childId);
20478 });
20479 }
20480
20481 (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
20482 orderedIds.push(topId);
20483 pushChildren(topId);
20484 });
20485
20486 orderedIds.forEach(function (id) {
20487 if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
20488 });
20489 updateSortButtons();
20490 }
20491
20492 function updateLanguageButtons() {
20493 languageButtons.forEach(function (button) {
20494 var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
20495 var isActive = languageValue === activeLanguage;
20496 button.classList.toggle("active", isActive);
20497 });
20498 }
20499
20500 function rowSelfMatches(row) {
20501 var kind = row.getAttribute("data-kind");
20502 var status = row.getAttribute("data-status");
20503 var language = (row.getAttribute("data-language") || "").toLowerCase();
20504 var name = row.getAttribute("data-name-lower") || "";
20505 var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
20506 var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
20507 var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
20508 var passesLanguage = !activeLanguage || language === activeLanguage;
20509 return passesFilter && passesSearch && passesLanguage;
20510 }
20511
20512 function hasMatchingDescendant(rowId) {
20513 return (childRows[rowId] || []).some(function (childId) {
20514 var childRow = rowById(childId);
20515 return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
20516 });
20517 }
20518
20519 function rowMatches(row) {
20520 if (rowSelfMatches(row)) return true;
20521 return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
20522 }
20523
20524 function resetViewState() {
20525 activeFilter = "all";
20526 activeLanguage = "";
20527 searchTerm = "";
20528 currentSortKey = null;
20529 currentSortOrder = "asc";
20530 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
20531 if (searchInput) searchInput.value = "";
20532 if (filterSelect) filterSelect.value = "all";
20533 updateLanguageButtons();
20534 }
20535
20536 function applyVisibility() {
20537 rows.forEach(function (row) {
20538 var visible = rowMatches(row) && !hasCollapsedAncestor(row);
20539 row.classList.toggle("hidden-by-filter", !visible);
20540 row.style.display = visible ? "grid" : "none";
20541 });
20542 buttons.forEach(function (button) {
20543 button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
20544 });
20545 if (filterSelect) filterSelect.value = activeFilter;
20546 }
20547
20548 var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
20549 var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
20550 var originalStats = {};
20551 buttons.forEach(function (btn) {
20552 var f = btn.getAttribute('data-filter');
20553 var v = btn.querySelector('.scope-stat-value');
20554 if (f && v) originalStats[f] = v.textContent;
20555 });
20556
20557 function applySubmoduleStats(statsJson) {
20558 try {
20559 var s = JSON.parse(statsJson);
20560 buttons.forEach(function (btn) {
20561 var f = btn.getAttribute('data-filter');
20562 var v = btn.querySelector('.scope-stat-value');
20563 if (!v) return;
20564 if (f === 'dir') v.textContent = s.dirs;
20565 else if (f === 'file') v.textContent = s.files;
20566 else if (f === 'supported') v.textContent = s.supported;
20567 else if (f === 'skipped') v.textContent = s.skipped;
20568 else if (f === 'unsupported') v.textContent = s.unsupported;
20569 });
20570 } catch (e) {}
20571 }
20572
20573 function restoreBaseRepoStats() {
20574 buttons.forEach(function (btn) {
20575 var f = btn.getAttribute('data-filter');
20576 var v = btn.querySelector('.scope-stat-value');
20577 if (v && originalStats[f]) v.textContent = originalStats[f];
20578 });
20579 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
20580 if (baseRepoBtn) baseRepoBtn.style.display = 'none';
20581 }
20582
20583 submoduleChips.forEach(function (chip) {
20584 chip.addEventListener('click', function () {
20585 var statsJson = chip.getAttribute('data-sub-stats');
20586 if (!statsJson) return;
20587 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
20588 chip.classList.add('active');
20589 applySubmoduleStats(statsJson);
20590 if (baseRepoBtn) baseRepoBtn.style.display = '';
20591 });
20592 });
20593
20594 if (baseRepoBtn) {
20595 baseRepoBtn.addEventListener('click', function () {
20596 restoreBaseRepoStats();
20597 resetViewState();
20598 sortSiblingRows();
20599 applyVisibility();
20600 });
20601 }
20602
20603 buttons.forEach(function (button) {
20604 button.addEventListener("click", function () {
20605 var filterValue = button.getAttribute("data-filter") || "all";
20606 if (filterValue === "reset-view") {
20607 restoreBaseRepoStats();
20608 resetViewState();
20609 sortSiblingRows();
20610 applyVisibility();
20611 return;
20612 }
20613 activeFilter = filterValue;
20614 applyVisibility();
20615 });
20616 });
20617
20618 rows.forEach(function (row) {
20619 updateToggleGlyph(row);
20620 var toggle = row.querySelector(".tree-toggle");
20621 if (toggle) {
20622 toggle.addEventListener("click", function () {
20623 var expanded = row.getAttribute("data-expanded") !== "false";
20624 row.setAttribute("data-expanded", expanded ? "false" : "true");
20625 updateToggleGlyph(row);
20626 applyVisibility();
20627 });
20628 }
20629 });
20630
20631 actionButtons.forEach(function (button) {
20632 button.addEventListener("click", function () {
20633 var action = button.getAttribute("data-explorer-action");
20634 if (action === "expand-all") {
20635 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
20636 } else if (action === "collapse-all") {
20637 dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
20638 } else if (action === "clear-filters") {
20639 resetViewState();
20640 }
20641 sortSiblingRows();
20642 applyVisibility();
20643 });
20644 });
20645
20646 if (filterSelect) {
20647 filterSelect.addEventListener("change", function () {
20648 activeFilter = filterSelect.value || "all";
20649 applyVisibility();
20650 });
20651 }
20652
20653 languageButtons.forEach(function (button) {
20654 button.addEventListener("click", function () {
20655 activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
20656 updateLanguageButtons();
20657 applyVisibility();
20658 });
20659 });
20660
20661 sortButtons.forEach(function (button) {
20662 button.addEventListener("click", function () {
20663 var sortKey = button.getAttribute("data-sort-key");
20664 if (currentSortKey === sortKey) {
20665 currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
20666 } else {
20667 currentSortKey = sortKey;
20668 currentSortOrder = "asc";
20669 }
20670 sortSiblingRows();
20671 applyVisibility();
20672 });
20673 });
20674
20675 if (searchInput) {
20676 searchInput.addEventListener("input", function () {
20677 searchTerm = searchInput.value.trim().toLowerCase();
20678 applyVisibility();
20679 });
20680 }
20681
20682 updateLanguageButtons();
20683 sortSiblingRows();
20684 applyVisibility();
20685 }
20686
20687 function loadPreview() {
20688 if (!previewPanel || !pathInput) return;
20689 // A fresh preview re-establishes the multi-repo gate; clear any prior ack.
20690 multiRepoBlocked = false;
20691 refreshStep1Gate();
20692 if (GIT_MODE) {
20693 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>';
20694 setPreviewLoading(false);
20695 return;
20696 }
20697 var path = pathInput.value.trim();
20698 var zeroWarn = document.getElementById('zero-files-warning');
20699 if (!path) {
20700 previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
20701 if (zeroWarn) zeroWarn.style.display = 'none';
20702 setPreviewLoading(false);
20703 return;
20704 }
20705 var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
20706 var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
20707 if (window._previewInterval) { clearInterval(window._previewInterval); window._previewInterval = null; }
20708 if (window._previewElapsedTimer) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; }
20709 var myGen = ++_previewGen;
20710 var _prevMsgs = [
20711 'Scanning directory structure\u2026',
20712 'Detecting file types\u2026',
20713 'Applying include / exclude filters\u2026',
20714 'Estimating file counts\u2026',
20715 'Building scope preview\u2026',
20716 'Almost there\u2026'
20717 ];
20718 var _prevMsgIdx = 0;
20719 var _prevStart = Date.now();
20720 previewPanel.innerHTML =
20721 '<div class="preview-loading">' +
20722 '<div class="preview-spinner"></div>' +
20723 '<div class="preview-loading-text">' +
20724 '<div class="preview-loading-msg" id="plm">' + _prevMsgs[0] + '</div>' +
20725 '<div class="preview-loading-elapsed" id="ple">0s elapsed</div>' +
20726 '</div></div>';
20727 var _sizeTextEl = document.getElementById('project-size-text');
20728 if (_sizeTextEl) _sizeTextEl.textContent = 'Project size: Detecting\u2026';
20729 window._previewInterval = setInterval(function() {
20730 if (myGen !== _previewGen) { clearInterval(window._previewInterval); window._previewInterval = null; return; }
20731 _prevMsgIdx = (_prevMsgIdx + 1) % _prevMsgs.length;
20732 var ml = document.getElementById('plm');
20733 if (ml) ml.textContent = _prevMsgs[_prevMsgIdx];
20734 }, 1500);
20735 window._previewElapsedTimer = setInterval(function() {
20736 if (myGen !== _previewGen) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; return; }
20737 var el = document.getElementById('ple');
20738 if (el) el.textContent = Math.round((Date.now() - _prevStart) / 1000) + 's elapsed';
20739 }, 1000);
20740 setPreviewLoading(true);
20741 var previewUrl = "/preview?path=" + encodeURIComponent(path)
20742 + "&include_globs=" + encodeURIComponent(includeValue)
20743 + "&exclude_globs=" + encodeURIComponent(excludeValue);
20744 fetch(previewUrl)
20745 .then(function (response) { return response.text(); })
20746 .then(function (html) {
20747 if (myGen !== _previewGen) return;
20748 clearInterval(window._previewInterval); window._previewInterval = null;
20749 clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
20750 setPreviewLoading(false);
20751 previewPanel.innerHTML = html;
20752 attachPreviewInteractions();
20753 syncPythonVisibility();
20754 updateReview();
20755 setTimeout(collapseLanguagePills, 50);
20756 var explorerWrap = previewPanel.querySelector('.explorer-wrap');
20757 var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
20758 var sizeText = document.getElementById('project-size-text');
20759 var sizeBtn = document.getElementById('project-size-btn');
20760 // In server mode with upload sizes available, keep the compressed/original pair.
20761 if (SERVER_MODE && window._lastUploadSizes) {
20762 var us = window._lastUploadSizes;
20763 if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(us.original_bytes) +
20764 ' \xb7 Compressed: ' + fmtBytes(us.compressed_bytes);
20765 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(us.original_bytes) +
20766 ' \u2014 Compressed archive size: ' + fmtBytes(us.compressed_bytes);
20767 } else if (sizeText && projectSize) {
20768 sizeText.textContent = 'Project size: ' + projectSize;
20769 if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
20770 } else if (sizeText) {
20771 sizeText.textContent = 'Project size: \u2014';
20772 }
20773 if (zeroWarn) {
20774 var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
20775 var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
20776 var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
20777 var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
20778 if (supportedCount === 0 && fileCount > 0) {
20779 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).';
20780 zeroWarn.style.display = '';
20781 } else {
20782 zeroWarn.style.display = 'none';
20783 }
20784 }
20785 })
20786 .catch(function (err) {
20787 if (myGen !== _previewGen) return;
20788 clearInterval(window._previewInterval); window._previewInterval = null;
20789 clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
20790 setPreviewLoading(false);
20791 previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
20792 });
20793 }
20794
20795 function pickDirectory(targetInput, kind) {
20796 if (!targetInput) {
20797 showBannerToast("Directory picker: input element not found.", true);
20798 return;
20799 }
20800 if (SERVER_MODE) {
20801 if (kind === 'output') {
20802 showBannerToast(
20803 'Server mode: type the output path directly into the field \u2014 the path must exist on the server, not your local machine.',
20804 false,
20805 { top: true, icon: '\u{1F4C1}' }
20806 );
20807 return;
20808 }
20809 var inputEl = kind === 'coverage'
20810 ? document.getElementById('cov-upload-input')
20811 : document.getElementById('dir-upload-input');
20812 if (!inputEl) return;
20813 inputEl.onchange = function () {
20814 var files = inputEl.files;
20815 if (!files || files.length === 0) return;
20816 var browseBtn = targetInput === pathInput ? browsePath : browseOutputDir;
20817 if (browseBtn) browseBtn.disabled = true;
20818
20819 function fileToBase64(file) {
20820 return new Promise(function (resolve, reject) {
20821 var reader = new FileReader();
20822 reader.onload = function () {
20823 var b64 = reader.result.split(',')[1];
20824 resolve(b64);
20825 };
20826 reader.onerror = reject;
20827 reader.readAsDataURL(file);
20828 });
20829 }
20830
20831 if (kind === 'coverage') {
20832 var f = files[0];
20833 if (previewPanel && targetInput === pathInput)
20834 previewPanel.innerHTML = '<div class="preview-error">Uploading coverage file\u2026</div>';
20835 fileToBase64(f).then(function (b64) {
20836 return fetch('/api/upload-file', {
20837 method: 'POST',
20838 headers: { 'Content-Type': 'application/json' },
20839 body: JSON.stringify({ filename: f.name, content: b64 })
20840 }).then(function (r) { return r.json(); });
20841 })
20842 .then(function (d) {
20843 if (d && d.tmp_path) {
20844 if (coverageInput) coverageInput.value = d.tmp_path;
20845 setCovStatus('idle');
20846 } else if (d && d.error) { showBannerToast(d.error, true); }
20847 })
20848 .catch(function (e) { showBannerToast('Upload failed: ' + String(e), true); })
20849 .finally(function () { if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; });
20850 } else {
20851 // ── Filter to source-code files only ─────────────────────────
20852 // Binary, generated, and dependency files (node_modules, .git,
20853 // build artifacts) are skipped so they are never uploaded.
20854 var CODE_EXTS = new Set([
20855 'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
20856 'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
20857 'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
20858 'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
20859 'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
20860 'asm','s','S','objc','lisp','el','rkt','ml','mli','ocaml','v','sv','vhd','vhdl',
20861 'tf','hcl','proto','thrift','avsc','graphql','gql'
20862 ]);
20863 var codeFiles = [];
20864 for (var i = 0; i < files.length; i++) {
20865 var f = files[i];
20866 var name = f.name;
20867 if (name === 'Makefile' || name === 'Dockerfile' || name === 'Gemfile' ||
20868 name === 'Rakefile' || name === 'Procfile' || name === 'Justfile') {
20869 codeFiles.push(f); continue;
20870 }
20871 var dot = name.lastIndexOf('.');
20872 if (dot >= 0 && CODE_EXTS.has(name.slice(dot + 1).toLowerCase())) codeFiles.push(f);
20873 }
20874 // Collect specific .git metadata files for server-side git detection.
20875 // These have no source extension so they are excluded by the loop above,
20876 // but the server needs them to read branch/commit/author without running git.
20877 var gitMetaFiles = [];
20878 for (var i = 0; i < files.length; i++) {
20879 var f = files[i];
20880 var rp = (f.webkitRelativePath || '').replace(/\\/g, '/');
20881 var gitIdx = rp.indexOf('/.git/');
20882 if (gitIdx < 0) continue;
20883 var gitRel = rp.slice(gitIdx + 1);
20884 if (gitRel === '.git/HEAD' || gitRel === '.git/packed-refs' ||
20885 gitRel === '.git/logs/HEAD' ||
20886 gitRel.startsWith('.git/refs/heads/') ||
20887 gitRel.startsWith('.git/refs/tags/')) {
20888 gitMetaFiles.push(f);
20889 }
20890 }
20891 var uploadFiles = codeFiles.concat(gitMetaFiles);
20892 var total = files.length;
20893 var kept = codeFiles.length;
20894 if (kept === 0) {
20895 if (previewPanel && targetInput === pathInput)
20896 previewPanel.innerHTML = '<div class="preview-error">No supported source files found in the selected folder (' + total.toLocaleString() + ' files scanned).</div>';
20897 if (browseBtn) browseBtn.disabled = false;
20898 inputEl.value = '';
20899 return;
20900 }
20901
20902 // ── Helper: apply upload result to UI ────────────────────────
20903 // sizes = {compressed_bytes, original_bytes} from the server response (server mode only).
20904 function applyUploadResult(tmpPath, sizes) {
20905 targetInput.value = tmpPath;
20906 scrollInputToEnd(targetInput);
20907 if (sizes && SERVER_MODE) {
20908 window._lastUploadSizes = sizes;
20909 // Immediately show both sizes before preview loads.
20910 var sizeText = document.getElementById('project-size-text');
20911 var sizeBtn = document.getElementById('project-size-btn');
20912 if (sizeText) {
20913 sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
20914 ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
20915 }
20916 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
20917 ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
20918 }
20919 if (targetInput === pathInput) {
20920 updateReportTitleFromPath();
20921 autoSetOutputDir(tmpPath);
20922 fetchProjectHistory(tmpPath);
20923 loadPreview();
20924 suggestCoverageFile(tmpPath);
20925 }
20926 updateReview();
20927 if (browseBtn) browseBtn.disabled = false;
20928 inputEl.value = '';
20929 }
20930
20931 // ── Path A: tar.gz via native CompressionStream (Chrome 80+, FF 113+, Safari 16.4+)
20932 if (typeof CompressionStream !== 'undefined') {
20933 if (previewPanel && targetInput === pathInput)
20934 previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
20935
20936 // Build a minimal POSIX ustar tar header for a single file entry.
20937 function buildUstarHeader(filePath, fileSize) {
20938 var BLOCK = 512;
20939 var hdr = new Uint8Array(BLOCK);
20940 var enc = new TextEncoder();
20941 function wStr(off, len, s) {
20942 var b = enc.encode(s);
20943 for (var i = 0; i < Math.min(b.length, len); i++) hdr[off + i] = b[i];
20944 }
20945 function wOct(off, len, val) {
20946 var s = val.toString(8);
20947 while (s.length < len - 1) s = '0' + s;
20948 wStr(off, len, s + '\0');
20949 }
20950 // Long-path split: ustar name ≤99 chars, prefix ≤154 chars.
20951 var name = filePath, prefix = '';
20952 if (filePath.length > 99) {
20953 var split = filePath.lastIndexOf('/', 154);
20954 if (split > 0 && filePath.length - split - 1 <= 99) {
20955 prefix = filePath.substring(0, split);
20956 name = filePath.substring(split + 1);
20957 } else { name = filePath.substring(0, 99); }
20958 }
20959 wStr(0, 100, name); // name
20960 wOct(100, 8, 0o000644); // mode
20961 wOct(108, 8, 0); // uid
20962 wOct(116, 8, 0); // gid
20963 wOct(124, 12, fileSize); // size
20964 wOct(136, 12, 0); // mtime (epoch)
20965 for (var i = 148; i < 156; i++) hdr[i] = 32; // checksum placeholder = spaces
20966 hdr[156] = 48; // type flag '0' = regular file
20967 wStr(157, 100, ''); // linkname
20968 wStr(257, 6, 'ustar'); // magic
20969 wStr(263, 2, '00'); // version
20970 wStr(265, 32, ''); // uname
20971 wStr(297, 32, ''); // gname
20972 wOct(329, 8, 0); // devmajor
20973 wOct(337, 8, 0); // devminor
20974 wStr(345, 155, prefix); // prefix
20975 // Compute checksum (sum of all bytes, placeholder = 32).
20976 var chk = 0;
20977 for (var i = 0; i < BLOCK; i++) chk += hdr[i];
20978 var cs = chk.toString(8);
20979 while (cs.length < 6) cs = '0' + cs;
20980 wStr(148, 8, cs + '\0 ');
20981 return hdr;
20982 }
20983
20984 // Build tar.gz one file at a time, piping through CompressionStream.
20985 // RAM usage = compressed output buffer + one file at a time.
20986 (async function () {
20987 try {
20988 var BLOCK = 512;
20989 var cs = new CompressionStream('gzip');
20990 var writer = cs.writable.getWriter();
20991 var chunks = [];
20992 var reader = cs.readable.getReader();
20993 var collecting = (async function () {
20994 while (true) { var r = await reader.read(); if (r.done) break; chunks.push(r.value); }
20995 })();
20996
20997 for (var i = 0; i < uploadFiles.length; i++) {
20998 var file = uploadFiles[i];
20999 var path = file.webkitRelativePath || file.name;
21000 var buf = await file.arrayBuffer();
21001 var data = new Uint8Array(buf);
21002 // Header block
21003 await writer.write(buildUstarHeader(path, data.length));
21004 // Data padded to 512-byte boundary
21005 if (data.length > 0) {
21006 var padded = Math.ceil(data.length / BLOCK) * BLOCK;
21007 var block = new Uint8Array(padded);
21008 block.set(data);
21009 await writer.write(block);
21010 }
21011 if ((i + 1) % 50 === 0 || i === uploadFiles.length - 1) {
21012 if (previewPanel && targetInput === pathInput)
21013 previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i + 1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
21014 }
21015 }
21016 // End-of-archive: two 512-byte zero blocks
21017 await writer.write(new Uint8Array(BLOCK * 2));
21018 await writer.close();
21019 await collecting;
21020
21021 var blob = new Blob(chunks, { type: 'application/gzip' });
21022 var sizeMB = (blob.size / 1048576).toFixed(1);
21023 if (previewPanel && targetInput === pathInput)
21024 previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + (total !== kept ? kept.toLocaleString() + ' of ' + total.toLocaleString() + ' files' : kept.toLocaleString() + ' files') + ')\u2026</div>';
21025
21026 var resp = await fetch('/api/upload-tarball', {
21027 method: 'POST',
21028 headers: { 'Content-Type': 'application/gzip' },
21029 body: blob
21030 });
21031 var d = await resp.json();
21032 if (d && d.tmp_path) {
21033 applyUploadResult(d.tmp_path, {
21034 compressed_bytes: d.compressed_bytes || 0,
21035 original_bytes: d.original_bytes || 0
21036 });
21037 } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
21038 } catch (e) {
21039 showBannerToast('Upload failed: ' + String(e), true);
21040 if (browseBtn) browseBtn.disabled = false;
21041 inputEl.value = '';
21042 }
21043 })();
21044
21045 } else {
21046 // ── Path B: Legacy fallback — sequential JSON+base64 batches ─
21047 // Used only on browsers that lack CompressionStream (pre-2023).
21048 var BATCH = 200;
21049 var batches = [];
21050 for (var b = 0; b < uploadFiles.length; b += BATCH) batches.push(uploadFiles.slice(b, b + BATCH));
21051 var totalBatches = batches.length;
21052 if (previewPanel && targetInput === pathInput)
21053 previewPanel.innerHTML = '<div class="preview-error">Uploading ' + kept.toLocaleString() + ' code file' + (kept === 1 ? '' : 's') + (total !== kept ? ' of ' + total.toLocaleString() + ' total' : '') + '\u2026</div>';
21054
21055 function sendBatch(idx, currentUploadId, lastTmpPath) {
21056 if (idx >= totalBatches) { applyUploadResult(lastTmpPath); return; }
21057 if (previewPanel && targetInput === pathInput && totalBatches > 1)
21058 previewPanel.innerHTML = '<div class="preview-error">Uploading batch ' + (idx + 1) + ' of ' + totalBatches + '\u2026</div>';
21059 Promise.all(batches[idx].map(function (file) {
21060 return fileToBase64(file).then(function (b64) {
21061 return { path: file.webkitRelativePath || file.name, content: b64 };
21062 });
21063 })).then(function (fileList) {
21064 var body = { files: fileList };
21065 if (currentUploadId) body.upload_id = currentUploadId;
21066 return fetch('/api/upload-directory', {
21067 method: 'POST', headers: { 'Content-Type': 'application/json' },
21068 body: JSON.stringify(body)
21069 }).then(function (r) { return r.json(); });
21070 }).then(function (d) {
21071 if (d && d.tmp_path) sendBatch(idx + 1, d.upload_id || currentUploadId, d.tmp_path);
21072 else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
21073 }).catch(function (e) {
21074 showBannerToast('Upload failed: ' + String(e), true);
21075 if (browseBtn) browseBtn.disabled = false; inputEl.value = '';
21076 });
21077 }
21078 sendBatch(0, null, '');
21079 }
21080 }
21081 };
21082 inputEl.click();
21083 return;
21084 }
21085
21086 var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
21087 if (browseButton) browseButton.disabled = true;
21088
21089 if (previewPanel && targetInput === pathInput) {
21090 previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
21091 }
21092
21093 fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "¤t=" + encodeURIComponent(targetInput.value || ""))
21094 .then(function (response) { return response.ok ? response.json() : { cancelled: true }; })
21095 .then(function (data) {
21096 if (data && data.selected_path) {
21097 targetInput.value = data.selected_path;
21098 scrollInputToEnd(targetInput);
21099
21100 if (targetInput === pathInput) {
21101 updateReportTitleFromPath();
21102 autoSetOutputDir(data.selected_path);
21103 fetchProjectHistory(data.selected_path);
21104 loadPreview();
21105 suggestCoverageFile(data.selected_path);
21106 }
21107
21108 updateReview();
21109 } else if (targetInput === pathInput) {
21110 loadPreview();
21111 }
21112 })
21113 .catch(function () {
21114 window.alert("Directory picker request failed.");
21115 if (previewPanel && targetInput === pathInput) {
21116 previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
21117 }
21118 })
21119 .finally(function () {
21120 if (browseButton) browseButton.disabled = false;
21121 });
21122 }
21123
21124 if (themeToggle) {
21125 themeToggle.addEventListener("click", function () {
21126 var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
21127 applyTheme(nextTheme);
21128 try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
21129 });
21130 }
21131
21132 stepButtons.forEach(function (button) {
21133 button.addEventListener("click", function () {
21134 var target = Number(button.getAttribute("data-step-target"));
21135 // Block jumping forward off step 1 while the preview / upload is running
21136 // or while a multi-repository selection is unacknowledged.
21137 if (step1ForwardBlocked() && currentStep === 1 && target > 1) return;
21138 setStep(target);
21139 });
21140 });
21141
21142 Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
21143 button.addEventListener("click", function () {
21144 var target = Number(button.getAttribute("data-step-target")) || 1;
21145 if (step1ForwardBlocked() && currentStep === 1 && target > 1) return;
21146 setStep(target);
21147 });
21148 });
21149
21150 // True when the project path is untouched from the bundled sample default.
21151 function isDefaultSamplePath() {
21152 return !GIT_MODE && pathInput && pathInput.value.trim() === "testing/fixtures/basic";
21153 }
21154
21155 var defaultPathOverlay = document.getElementById("default-path-overlay");
21156 function closeDefaultPathModal() {
21157 if (defaultPathOverlay) defaultPathOverlay.classList.remove("open");
21158 }
21159 function openDefaultPathModal() {
21160 if (defaultPathOverlay) defaultPathOverlay.classList.add("open");
21161 }
21162
21163 Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
21164 // Skip buttons that aren't real wizard navigation (e.g. modal action buttons
21165 // that borrow the .next-step style class but carry no data-next target).
21166 if (!button.hasAttribute("data-next")) return;
21167 button.addEventListener("click", function () {
21168 // Guard step 1 → 2: block while the scope preview / upload is still running
21169 // or while a multi-repository selection is unacknowledged.
21170 if (button.getAttribute("data-next") === "2" && step1ForwardBlocked()) return;
21171 // Guard step 1 → 2: warn when the project path is still the sample default.
21172 if (button.getAttribute("data-next") === "2" && isDefaultSamplePath()) {
21173 openDefaultPathModal();
21174 return;
21175 }
21176 updateReview();
21177 setStep(Number(button.getAttribute("data-next")));
21178 });
21179 });
21180
21181 Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
21182 if (!button.hasAttribute("data-prev")) return;
21183 button.addEventListener("click", function () {
21184 setStep(Number(button.getAttribute("data-prev")));
21185 });
21186 });
21187
21188 // Default-sample-path confirmation modal wiring.
21189 var defaultPathProceed = document.getElementById("default-path-proceed");
21190 if (defaultPathProceed) {
21191 defaultPathProceed.addEventListener("click", function () {
21192 closeDefaultPathModal();
21193 updateReview();
21194 setStep(2);
21195 });
21196 }
21197 var defaultPathCancel = document.getElementById("default-path-cancel");
21198 if (defaultPathCancel) {
21199 defaultPathCancel.addEventListener("click", function () {
21200 closeDefaultPathModal();
21201 if (pathInput) { pathInput.focus(); pathInput.select(); }
21202 });
21203 }
21204 if (defaultPathOverlay) {
21205 defaultPathOverlay.addEventListener("click", function (e) {
21206 if (e.target === defaultPathOverlay) closeDefaultPathModal();
21207 });
21208 }
21209 document.addEventListener("keydown", function (e) {
21210 if (e.key === "Escape" && defaultPathOverlay && defaultPathOverlay.classList.contains("open")) {
21211 closeDefaultPathModal();
21212 }
21213 });
21214
21215 document.addEventListener("keydown", function (e) {
21216 var tag = (document.activeElement || {}).tagName || "";
21217 if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
21218 if (e.altKey || e.ctrlKey || e.metaKey) return;
21219 if (e.key === "ArrowRight" && currentStep < 4) {
21220 if (currentStep === 1 && step1ForwardBlocked()) return;
21221 if (currentStep === 1 && isDefaultSamplePath()) { openDefaultPathModal(); return; }
21222 updateReview(); setStep(currentStep + 1);
21223 }
21224 else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
21225 });
21226
21227 if (useSamplePath) {
21228 useSamplePath.addEventListener("click", function () {
21229 pathInput.value = "testing/fixtures/basic";
21230 updateReportTitleFromPath();
21231 autoSetOutputDir("testing/fixtures/basic");
21232 loadPreview();
21233 suggestCoverageFile("testing/fixtures/basic");
21234 });
21235 }
21236
21237 if (useDefaultOutput) {
21238 useDefaultOutput.addEventListener("click", function () {
21239 delete outputDirInput.dataset.userEdited;
21240 autoSetOutputDir(pathInput ? pathInput.value : "");
21241 updateReview();
21242 });
21243 }
21244
21245 if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
21246 if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
21247
21248 // ── Drag-and-drop directory upload (server mode only) ─────────────────
21249 // Dropping a folder onto the path field bypasses Chrome's
21250 // "Upload X files to this site?" confirmation dialog.
21251 async function readDirRecursively(dirEntry, basePath) {
21252 var reader = dirEntry.createReader();
21253 var all = [];
21254 for (;;) {
21255 var batch = await new Promise(function(res) { reader.readEntries(res, function() { res([]); }); });
21256 if (!batch.length) break;
21257 for (var i = 0; i < batch.length; i++) all.push(batch[i]);
21258 }
21259 var SKIP = new Set(['node_modules','.git','.hg','vendor','dist','build','target','__pycache__','.svn','.idea','.vscode']);
21260 var out = [];
21261 for (var i = 0; i < all.length; i++) {
21262 var sub = all[i];
21263 if (sub.isFile) {
21264 var f = await new Promise(function(res) { sub.file(res); });
21265 out.push({ file: f, path: basePath + '/' + sub.name });
21266 } else if (sub.isDirectory && !SKIP.has(sub.name)) {
21267 var nested = await readDirRecursively(sub, basePath + '/' + sub.name);
21268 for (var j = 0; j < nested.length; j++) out.push(nested[j]);
21269 }
21270 }
21271 return out;
21272 }
21273
21274 function setupPathDropZone() {
21275 if (!SERVER_MODE || !pathInput) return;
21276 var CODE_EXTS = new Set([
21277 'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
21278 'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
21279 'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
21280 'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
21281 'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
21282 'asm','s','S','lisp','el','rkt','ml','mli','tf','hcl','proto','thrift','graphql','gql'
21283 ]);
21284 pathInput.addEventListener('dragover', function(e) {
21285 e.preventDefault();
21286 pathInput.classList.add('drag-over');
21287 });
21288 pathInput.addEventListener('dragleave', function() { pathInput.classList.remove('drag-over'); });
21289 pathInput.addEventListener('drop', function(e) {
21290 e.preventDefault();
21291 pathInput.classList.remove('drag-over');
21292 var items = e.dataTransfer.items;
21293 if (!items || !items.length) return;
21294 var dirEntry = null;
21295 for (var i = 0; i < items.length; i++) {
21296 var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
21297 if (entry && entry.isDirectory) { dirEntry = entry; break; }
21298 }
21299 if (!dirEntry) { showBannerToast('Drop a project folder (not individual files).', true); return; }
21300 var btn = browsePath;
21301 if (btn) btn.disabled = true;
21302 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Reading folder contents\u2026</div>';
21303
21304 readDirRecursively(dirEntry, dirEntry.name).then(async function(allEntries) {
21305 var total = allEntries.length;
21306 var codeEntries = allEntries.filter(function(e) {
21307 var n = e.file.name;
21308 if (n === 'Makefile' || n === 'Dockerfile' || n === 'Gemfile' || n === 'Rakefile' || n === 'Procfile' || n === 'Justfile') return true;
21309 var dot = n.lastIndexOf('.');
21310 return dot >= 0 && CODE_EXTS.has(n.slice(dot + 1).toLowerCase());
21311 });
21312 var kept = codeEntries.length;
21313 if (kept === 0) {
21314 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">No supported source files found (' + total.toLocaleString() + ' files scanned).</div>';
21315 if (btn) btn.disabled = false; return;
21316 }
21317
21318 function finish(tmpPath, sizes) {
21319 pathInput.value = tmpPath;
21320 scrollInputToEnd(pathInput);
21321 if (sizes) {
21322 window._lastUploadSizes = sizes;
21323 var sizeText = document.getElementById('project-size-text');
21324 var sizeBtn = document.getElementById('project-size-btn');
21325 if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
21326 ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
21327 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
21328 ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
21329 }
21330 updateReportTitleFromPath();
21331 autoSetOutputDir(tmpPath);
21332 fetchProjectHistory(tmpPath);
21333 loadPreview();
21334 suggestCoverageFile(tmpPath);
21335 updateReview();
21336 if (btn) btn.disabled = false;
21337 }
21338
21339 if (typeof CompressionStream === 'undefined') {
21340 showBannerToast('Your browser lacks CompressionStream. Use the \u201cUpload\u201d button instead.', true);
21341 if (btn) btn.disabled = false; return;
21342 }
21343
21344 try {
21345 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
21346 var BLOCK = 512;
21347 var cs = new CompressionStream('gzip');
21348 var wtr = cs.writable.getWriter();
21349 var chunks = [];
21350 var rdr = cs.readable.getReader();
21351 var collecting = (async function() { while (true) { var r = await rdr.read(); if (r.done) break; chunks.push(r.value); } })();
21352
21353 function buildHdr(fp, sz) {
21354 var hdr = new Uint8Array(BLOCK);
21355 var enc = new TextEncoder();
21356 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]; }
21357 function wO(o, l, v) { var s = v.toString(8); while (s.length < l - 1) s = '0' + s; wS(o, l, s + '\0'); }
21358 var nm = fp, pfx = '';
21359 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); } }
21360 wS(0,100,nm); wO(100,8,0o000644); wO(108,8,0); wO(116,8,0); wO(124,12,sz); wO(136,12,0);
21361 for (var i = 148; i < 156; i++) hdr[i] = 32;
21362 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);
21363 var chk = 0; for (var i = 0; i < BLOCK; i++) chk += hdr[i];
21364 var cv = chk.toString(8); while (cv.length < 6) cv = '0' + cv; wS(148,8,cv+'\0 ');
21365 return hdr;
21366 }
21367
21368 for (var i = 0; i < codeEntries.length; i++) {
21369 var ce = codeEntries[i];
21370 var buf = await ce.file.arrayBuffer();
21371 var data = new Uint8Array(buf);
21372 await wtr.write(buildHdr(ce.path, data.length));
21373 if (data.length > 0) { var padded = Math.ceil(data.length / BLOCK) * BLOCK; var blk = new Uint8Array(padded); blk.set(data); await wtr.write(blk); }
21374 if ((i + 1) % 50 === 0 || i === codeEntries.length - 1)
21375 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i+1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
21376 }
21377 await wtr.write(new Uint8Array(BLOCK * 2));
21378 await wtr.close();
21379 await collecting;
21380
21381 var blob = new Blob(chunks, { type: 'application/gzip' });
21382 var sizeMB = (blob.size / 1048576).toFixed(1);
21383 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + kept.toLocaleString() + ' files)\u2026</div>';
21384 var resp = await fetch('/api/upload-tarball', { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: blob });
21385 var d = await resp.json();
21386 if (d && d.tmp_path) {
21387 finish(d.tmp_path, { compressed_bytes: d.compressed_bytes || 0, original_bytes: d.original_bytes || 0 });
21388 } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (btn) btn.disabled = false; }
21389 } catch (err) {
21390 showBannerToast('Upload failed: ' + String(err), true);
21391 if (btn) btn.disabled = false;
21392 }
21393 }).catch(function(err) {
21394 showBannerToast('Could not read folder: ' + String(err), true);
21395 if (btn) btn.disabled = false;
21396 });
21397 });
21398 }
21399 setupPathDropZone();
21400 if (browseCoverage) {
21401 browseCoverage.addEventListener("click", function () {
21402 pickDirectory(coverageInput || pathInput, "coverage");
21403 });
21404 }
21405
21406 function setCovStatus(state, opts) {
21407 if (!covScanStatus) return;
21408 opts = opts || {};
21409 covScanStatus.className = "cov-scan-status cov-scan-" + state;
21410 if (state === "idle") { covScanStatus.innerHTML = ""; return; }
21411 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>';
21412 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>';
21413 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>';
21414 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>';
21415 var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
21416 var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
21417 if (state === "scanning") {
21418 html += '<div class="cov-scan-title">Scanning project for coverage files\u2026</div>';
21419 } else if (state === "found") {
21420 var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
21421 html += '<div class="cov-scan-title">Coverage file auto-detected! ' + tb + '</div>';
21422 html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
21423 html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove</button></div>';
21424 } else if (state === "hint") {
21425 var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
21426 html += '<div class="cov-scan-title">' + tb2 + ' project — no coverage report found yet</div>';
21427 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>';
21428 } else if (state === "none") {
21429 html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
21430 html += '<div class="cov-scan-sub">Supported: LCOV\u00a0.info · Cobertura\u00a0XML · JaCoCo\u00a0XML · coverage.py\u00a0JSON · Istanbul\u00a0JSON</div>';
21431 }
21432 html += '</div></div>';
21433 covScanStatus.innerHTML = html;
21434 if (state === "found") {
21435 var useBtn = covScanStatus.querySelector(".cov-scan-use");
21436 if (useBtn) useBtn.addEventListener("click", function () {
21437 if (coverageInput) coverageInput.value = "";
21438 covAutoFilled = false;
21439 setCovStatus("idle");
21440 });
21441 }
21442 }
21443
21444 function suggestCoverageFile(projectPath) {
21445 if (!coverageInput || !covScanStatus) return;
21446 if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
21447 if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
21448 clearTimeout(coverageSuggestTimer);
21449 if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
21450 setCovStatus("scanning");
21451 coverageSuggestTimer = setTimeout(function () {
21452 fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
21453 .then(function (r) { return r.json(); })
21454 .then(function (d) {
21455 if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
21456 if (!d) { setCovStatus("none"); return; }
21457 if (d.found) {
21458 if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
21459 setCovStatus("found", { found: d.found, tool: d.tool });
21460 } else if (d.tool && d.hint) {
21461 setCovStatus("hint", { tool: d.tool, hint: d.hint });
21462 } else {
21463 setCovStatus("none");
21464 }
21465 })
21466 .catch(function () { setCovStatus("idle"); });
21467 }, 600);
21468 }
21469
21470 if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
21471
21472 if (coverageInput) coverageInput.addEventListener("input", function () {
21473 covAutoFilled = false;
21474 if (!this.value.trim()) setCovStatus("idle");
21475 });
21476
21477 // ── Language pill overflow: collapse to "+N more" chip ─────────────
21478 function collapseLanguagePills() {
21479 var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
21480 rows.forEach(function(row) {
21481 // Remove any previous overflow chip
21482 var prev = row.querySelector('.lang-overflow-chip');
21483 if (prev) prev.remove();
21484 var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
21485 pills.forEach(function(p) { p.style.display = ''; });
21486 if (!pills.length) return;
21487
21488 // Measure after restoring all pills
21489 var containerRight = row.getBoundingClientRect().right;
21490 var hidden = [];
21491 for (var i = pills.length - 1; i >= 1; i--) {
21492 var rect = pills[i].getBoundingClientRect();
21493 if (rect.right > containerRight + 2) {
21494 hidden.unshift(pills[i]);
21495 pills[i].style.display = 'none';
21496 } else {
21497 break;
21498 }
21499 }
21500
21501 if (hidden.length) {
21502 var chip = document.createElement('button');
21503 chip.type = 'button';
21504 chip.className = 'language-pill lang-overflow-chip';
21505 var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
21506 chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
21507 row.appendChild(chip);
21508 }
21509 });
21510 }
21511
21512 // Run after preview loads (preview panel populates language pills)
21513 var _origLoadPreviewCb = window.__previewLoaded;
21514 document.addEventListener('previewLoaded', collapseLanguagePills);
21515 window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
21516 setTimeout(collapseLanguagePills, 400);
21517
21518 // ── Project history & output dir auto-set ──────────────────────────
21519 var wsOutputRoot = document.getElementById("ws-output-root");
21520 var wsScanCount = document.getElementById("ws-scan-count");
21521 var wsLastScan = document.getElementById("ws-last-scan");
21522 var historyBadge = document.getElementById("path-history-badge");
21523 var historyTimer = null;
21524
21525 var wsOutputLink = document.getElementById("ws-output-link");
21526 function syncStripOutputRoot() {
21527 var val = outputDirInput ? outputDirInput.value : "";
21528 var display = val || "project/sloc";
21529 if (wsOutputRoot) wsOutputRoot.textContent = display;
21530 if (wsOutputLink) wsOutputLink.dataset.folder = val;
21531 }
21532
21533 function scrollInputToEnd(input) {
21534 if (!input) return;
21535 // Defer so the DOM has the new value before we measure scroll width.
21536 requestAnimationFrame(function () {
21537 input.scrollLeft = input.scrollWidth;
21538 input.selectionStart = input.selectionEnd = input.value.length;
21539 });
21540 }
21541
21542 function autoSetOutputDir(projectPath) {
21543 if (!outputDirInput || outputDirInput.dataset.userEdited) return;
21544 if (GIT_MODE && GIT_OUTPUT_DIR) {
21545 outputDirInput.value = GIT_OUTPUT_DIR;
21546 scrollInputToEnd(outputDirInput);
21547 syncStripOutputRoot();
21548 updateReview();
21549 return;
21550 }
21551 if (!projectPath || !projectPath.trim()) return;
21552 var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
21553 outputDirInput.value = cleaned + "/sloc";
21554 scrollInputToEnd(outputDirInput);
21555 syncStripOutputRoot();
21556 updateReview();
21557 }
21558
21559 var wsBranch = document.getElementById("ws-branch");
21560
21561 function fetchProjectHistory(projectPath) {
21562 if (!projectPath || !projectPath.trim()) {
21563 if (wsScanCount) wsScanCount.textContent = "\u2014";
21564 if (wsLastScan) wsLastScan.textContent = "\u2014";
21565 if (wsBranch) wsBranch.textContent = "\u2014";
21566 if (historyBadge) historyBadge.style.display = "none";
21567 return;
21568 }
21569 fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
21570 .then(function (r) { return r.ok ? r.json() : null; })
21571 .then(function (data) {
21572 if (!data) return;
21573 var countStr = data.scan_count > 0
21574 ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
21575 : "never";
21576 var tsStr = data.last_scan_timestamp
21577 ? data.last_scan_timestamp.replace(" UTC","")
21578 : "\u2014";
21579 if (wsScanCount) wsScanCount.textContent = countStr;
21580 if (wsLastScan) wsLastScan.textContent = tsStr;
21581 if (wsBranch) wsBranch.textContent = data.last_git_branch || "\u2014";
21582 if (data.scan_count > 0) {
21583 if (historyBadge) {
21584 var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
21585 historyBadge.textContent = data.scan_count + " previous scan" +
21586 (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
21587 "Last: " + (data.last_scan_timestamp || "\u2014") +
21588 " \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.";
21589 historyBadge.className = "path-history-badge found";
21590 historyBadge.style.display = "";
21591 }
21592 } else {
21593 if (historyBadge) historyBadge.style.display = "none";
21594 }
21595 })
21596 .catch(function () {});
21597 }
21598
21599 function onPathChange() {
21600 var val = pathInput ? pathInput.value : "";
21601 // Discard stale upload sizes when the user edits the path manually.
21602 window._lastUploadSizes = null;
21603 updateReportTitleFromPath();
21604 autoSetOutputDir(val);
21605 updateSidebarSummary();
21606 clearTimeout(historyTimer);
21607 historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
21608 if (previewTimer) clearTimeout(previewTimer);
21609 previewTimer = setTimeout(loadPreview, 280);
21610 suggestCoverageFile(val);
21611 }
21612
21613 if (pathInput) {
21614 pathInput.addEventListener("input", onPathChange);
21615 }
21616
21617 if (outputDirInput) {
21618 outputDirInput.addEventListener("input", function () {
21619 outputDirInput.dataset.userEdited = "1";
21620 syncStripOutputRoot();
21621 updateReview();
21622 });
21623 }
21624
21625 [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
21626 if (!node) return;
21627 node.addEventListener("input", function () {
21628 updateReview();
21629 if (previewTimer) clearTimeout(previewTimer);
21630 previewTimer = setTimeout(loadPreview, 280);
21631 });
21632 });
21633
21634 ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
21635 var node = document.getElementById(id);
21636 if (node) node.addEventListener("change", updateReview);
21637 });
21638
21639 if (reportTitleInput) {
21640 reportTitleInput.addEventListener("input", function () {
21641 reportTitleTouched = reportTitleInput.value.trim().length > 0;
21642 updateReportTitleFromPath();
21643 updateReview();
21644 });
21645 }
21646
21647 if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
21648 if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
21649 if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
21650 if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
21651
21652 if (coverageInput) {
21653 coverageInput.addEventListener("input", function () {
21654 if (coverageInput.value.trim()) setCovStatus("idle");
21655 });
21656 }
21657
21658 if (form && loading && submitButton) {
21659 form.addEventListener("submit", function (e) {
21660 e.preventDefault();
21661 submitButton.disabled = true;
21662 submitButton.textContent = "Scanning...";
21663 startAsyncAnalysis(new FormData(form));
21664 });
21665 }
21666
21667 function openPath(folder) {
21668 if (!folder) return;
21669 fetch('/open-path?path=' + encodeURIComponent(folder))
21670 .then(function (r) { return r.json(); })
21671 .then(function (d) {
21672 if (d && d.server_mode_disabled)
21673 showBannerToast(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
21674 })
21675 .catch(function () {});
21676 }
21677
21678 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
21679 btn.addEventListener('click', function () {
21680 openPath(btn.getAttribute('data-folder') || btn.dataset.folder || '');
21681 });
21682 });
21683
21684 // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
21685 if (wsOutputLink) {
21686 wsOutputLink.addEventListener('click', function () {
21687 openPath(wsOutputLink.dataset.folder || '');
21688 });
21689 }
21690
21691 loadSavedTheme();
21692 updateMixedPolicyUI();
21693 updatePythonDocstringUI();
21694 applyScanPreset();
21695 updatePresetDescriptions();
21696 applyArtifactPreset();
21697 updateReview();
21698 updateScrollProgress(); // initialise bar to 0% (step 1)
21699 window.addEventListener("scroll", updateScrollProgress, { passive: true });
21700 onPathChange(); // seed output dir, history badge, and preview from initial path
21701 updateStepNav(1);
21702
21703 // Restore step from URL hash on initial load (e.g., back-forward cache)
21704 (function() {
21705 var hashMatch = location.hash.match(/^#step([1-4])$/);
21706 if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
21707 })();
21708
21709 (function randomizeWatermarks() {
21710 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
21711 if (!wms.length) return;
21712 var placed = [];
21713 function tooClose(top, left) {
21714 for (var i = 0; i < placed.length; i++) {
21715 var dt = Math.abs(placed[i][0] - top);
21716 var dl = Math.abs(placed[i][1] - left);
21717 if (dt < 16 && dl < 12) return true;
21718 }
21719 return false;
21720 }
21721 function pick(leftBand) {
21722 for (var attempt = 0; attempt < 50; attempt++) {
21723 var top = Math.random() * 88 + 2;
21724 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21725 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
21726 }
21727 var top = Math.random() * 88 + 2;
21728 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
21729 placed.push([top, left]);
21730 return [top, left];
21731 }
21732 var half = Math.floor(wms.length / 2);
21733 wms.forEach(function (img, i) {
21734 var pos = pick(i < half);
21735 var size = Math.floor(Math.random() * 80 + 110);
21736 var rot = (Math.random() * 360).toFixed(1);
21737 var op = (Math.random() * 0.08 + 0.13).toFixed(2);
21738 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;
21739 });
21740 })();
21741
21742 (function spawnCodeParticles() {
21743 var container = document.getElementById('code-particles');
21744 if (!container) return;
21745 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'];
21746 for (var i = 0; i < 38; i++) {
21747 (function(idx) {
21748 var el = document.createElement('span');
21749 el.className = 'code-particle';
21750 el.textContent = snippets[idx % snippets.length];
21751 var left = Math.random() * 94 + 2;
21752 var top = Math.random() * 88 + 6;
21753 var dur = (Math.random() * 10 + 9).toFixed(1);
21754 var delay = (Math.random() * 18).toFixed(1);
21755 var rot = (Math.random() * 26 - 13).toFixed(1);
21756 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
21757 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';
21758 container.appendChild(el);
21759 })(i);
21760 }
21761 })();
21762 })();
21763 </script>
21764 <script nonce="{{ csp_nonce }}">
21765 (function () {
21766 var raw = {{ prefill_json|safe }};
21767 if (!raw || typeof raw !== 'object' || !raw.path) return;
21768 function setVal(id, val) { var el = document.getElementById(id); if (el) { el.value = val; if (id === 'output_dir') scrollInputToEnd(el); } }
21769 function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
21770 function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
21771 setVal('path', raw.path || '');
21772 setVal('include_globs', raw.include_globs || '');
21773 setVal('exclude_globs', raw.exclude_globs || '');
21774 setVal('output_dir', raw.output_dir || '');
21775 setVal('report_title', raw.report_title || '');
21776 if (raw.submodule_breakdown) setChecked('submodule_breakdown', true);
21777 setSelect('mixed_line_policy', raw.mixed_line_policy || 'code_only');
21778 setChecked('python_docstrings_as_comments', !!raw.python_docstrings_as_comments);
21779 setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
21780 setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
21781 setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
21782 if (raw.include_lockfiles) setSelect('include_lockfiles', 'enabled');
21783 setSelect('binary_file_behavior', raw.binary_file_behavior || 'skip');
21784 setChecked('generate_html', raw.generate_html !== false);
21785 setChecked('generate_pdf', !!raw.generate_pdf);
21786 if (raw.continuation_line_policy) setSelect('continuation_line_policy', raw.continuation_line_policy);
21787 if (raw.blank_in_block_comment_policy) setSelect('blank_in_block_comment_policy', raw.blank_in_block_comment_policy);
21788 setSelect('count_compiler_directives', raw.count_compiler_directives === false ? 'disabled' : 'enabled');
21789 setSelect('style_analysis_enabled', raw.style_analysis_enabled === false ? 'disabled' : 'enabled');
21790 if (raw.style_col_threshold) setSelect('style_col_threshold', String(raw.style_col_threshold));
21791 if (raw.style_score_threshold) setSelect('style_score_threshold', String(raw.style_score_threshold));
21792 if (raw.style_lang_scope) setSelect('style_lang_scope', raw.style_lang_scope);
21793 if (raw.coverage_file) setVal('coverage_file', raw.coverage_file);
21794 if (raw.cocomo_mode) setSelect('cocomo_mode', raw.cocomo_mode);
21795 if (raw.complexity_alert) setVal('complexity_alert', String(raw.complexity_alert));
21796 if (raw.activity_window !== undefined && raw.activity_window !== null) setVal('activity_window', String(raw.activity_window));
21797 setSelect('exclude_duplicates', raw.exclude_duplicates ? 'enabled' : 'disabled');
21798 // Trigger dynamic UI updates after pre-fill.
21799 setTimeout(function () {
21800 var pathEl = document.getElementById('path');
21801 if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
21802 var policyEl = document.getElementById('mixed_line_policy');
21803 if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
21804 }, 80);
21805 })();
21806 </script>
21807 <script nonce="{{ csp_nonce }}">
21808 (function(){
21809 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'}];
21810 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);});}
21811 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
21812 function init(){
21813 var btn=document.getElementById('settings-btn');if(!btn)return;
21814 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
21815 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>';
21816 document.body.appendChild(m);
21817 var g=document.getElementById('scheme-grid');
21818 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);});
21819 var cl=document.getElementById('settings-close');
21820 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);});})();
21821 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');});
21822 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
21823 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
21824 }
21825 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
21826 }());
21827 </script>
21828 <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
21829 <div class="wb-ftip-arrow"></div>
21830 <span id="wb-ftip-text"></span>
21831 </div>
21832 <script nonce="{{ csp_nonce }}">(function(){
21833 var tip=document.getElementById('wb-ftip');
21834 var txt=document.getElementById('wb-ftip-text');
21835 var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
21836 if(!tip||!txt)return;
21837 function pos(el){
21838 var r=el.getBoundingClientRect();
21839 tip.style.display='block';
21840 var tw=tip.offsetWidth;
21841 var lx=r.left+r.width/2-tw/2;
21842 if(lx<8)lx=8;
21843 if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
21844 tip.style.left=lx+'px';
21845 tip.style.top=(r.bottom+8)+'px';
21846 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';}
21847 }
21848 document.querySelectorAll('[data-wb-tip]').forEach(function(el){
21849 el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
21850 el.addEventListener('mouseleave',function(){tip.style.display='none';});
21851 });
21852 window.addEventListener('blur',function(){tip.style.display='none';});
21853 document.addEventListener('visibilitychange',function(){if(document.hidden)tip.style.display='none';});
21854 })();
21855 (function(){
21856 function fixArtifactHintSpacing(){
21857 var grid=document.querySelector('.artifact-grid');
21858 if(grid){grid.style.setProperty('margin-bottom','48px','important');}
21859 }
21860 if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
21861 }());
21862 (function(){
21863 var dot=document.getElementById('status-dot');
21864 var pingEl=document.getElementById('server-ping-ms');
21865 var tipEl=document.getElementById('server-tip-ping');
21866 var fm=document.getElementById('footer-mode');
21867 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)';}}
21868 function doPing(){
21869 var t0=performance.now();
21870 fetch('/healthz',{cache:'no-store'})
21871 .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);})
21872 .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)';}});
21873 }
21874 doPing();
21875 setInterval(doPing,5000);
21876 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');}
21877 })();
21878 </script>
21879 <span id="page-bottom" aria-hidden="true" style="display:block;height:0;"></span>
21880 <footer class="site-footer">
21881 local code analysis - metrics, history and reports
21882 · <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>
21883 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
21884 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
21885 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
21886 · <a href="/api-docs" rel="noopener">REST API</a>
21887 </footer>
21888</body>
21889</html>
21890"##,
21891 ext = "html"
21892)]
21893struct IndexTemplate {
21894 version: &'static str,
21895 prefill_json: String,
21896 csp_nonce: String,
21897 git_repo: String,
21898 git_ref: String,
21899 git_label_json: String,
21900 git_output_dir_json: String,
21901 server_mode: bool,
21902}
21903
21904#[derive(Template)]
21907#[template(
21908 source = r##"
21909<!doctype html>
21910<html lang="en">
21911<head>
21912 <meta charset="utf-8">
21913 <meta name="viewport" content="width=device-width, initial-scale=1">
21914 <title>OxideSLOC — local code analysis - metrics, history and reports</title>
21915 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
21916 <script type="application/ld+json">
21917 {
21918 "@context": "https://schema.org",
21919 "@type": "SoftwareApplication",
21920 "name": "oxide-sloc",
21921 "applicationCategory": "DeveloperApplication",
21922 "operatingSystem": "Windows, Linux",
21923 "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.",
21924 "softwareVersion": "{{ version }}",
21925 "author": { "@type": "Person", "name": "Nima Shafie", "url": "https://github.com/NimaShafie" },
21926 "license": "https://www.gnu.org/licenses/agpl-3.0.html",
21927 "url": "https://github.com/oxide-sloc/oxide-sloc",
21928 "downloadUrl": "https://github.com/oxide-sloc/oxide-sloc/releases",
21929 "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",
21930 "programmingLanguage": "Rust",
21931 "keywords": "sloc, code analysis, source lines of code, metrics, MCP, AI agent"
21932 }
21933 </script>
21934 <style nonce="{{ csp_nonce }}">
21935 :root {
21936 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
21937 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
21938 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
21939 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
21940 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
21941 }
21942 body.dark-theme {
21943 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
21944 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
21945 }
21946 *{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;}
21947 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21948 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
21949 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
21950 .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;}
21951 @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));}}
21952 .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);}
21953 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
21954 .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));}
21955 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
21956 .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;}
21957 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
21958 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
21959 @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; } }
21960 .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;}
21961 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
21962 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
21963 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
21964 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
21965 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
21966 .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;}
21967 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
21968 .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);}
21969 .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;}
21970 .settings-close:hover{color:var(--text);background:var(--surface-2);}
21971 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
21972 .settings-modal-body{padding:14px 16px 16px;}
21973 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
21974 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
21975 .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;}
21976 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
21977 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
21978 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
21979 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
21980 .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;}
21981 .tz-select:focus{border-color:var(--oxide);}
21982 .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;}
21983 .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;}
21984 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
21985 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
21986 .hero{text-align:center;margin:0 auto 18px;}
21987 .hero-logo-wrap{display:inline-block;cursor:default;}
21988 .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;}
21989 .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;}
21990 .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
21991 .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;}
21992 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%);}
21993 .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;
21994 background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
21995 background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
21996 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;}
21997 @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
21998 @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
21999 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;}
22000 .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
22001 .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;}
22002 @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
22003 .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
22004 .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
22005 .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
22006 .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
22007 @media(max-width:900px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr 1fr;}}
22008 @media(max-width:480px){.card-section-grid-2,.card-section-grid-3{grid-template-columns:1fr;}}
22009 .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;}
22010 .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;}
22011 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
22012 @media(prefers-reduced-motion:reduce){.action-card,.lan-card{animation:none;}}
22013 .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
22014 .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);}
22015 .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
22016 .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
22017 .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);}
22018 .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);}
22019 .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);}
22020 .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
22021 .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
22022 .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;}
22023 body.dark-theme .action-card-cta{color:var(--oxide);}
22024 .action-card.view .action-card-cta{color:var(--accent-2);}
22025 body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
22026 .action-card.compare .action-card-cta{color:#7c3aed;}
22027 body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
22028 .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);}
22029 .action-card.git-tools .action-card-cta{color:#15803d;}
22030 body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
22031 .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);}
22032 .action-card.trend .action-card-cta{color:#0e7490;}
22033 body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
22034 .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);}
22035 .action-card.automation .action-card-cta{color:#b45309;}
22036 body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
22037 .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);}
22038 .action-card.test-metrics .action-card-cta{color:#be185d;}
22039 body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
22040 .action-card:hover .action-card-cta{gap:12px;}
22041 .action-card.card-split{flex-direction:row;align-items:stretch;}
22042 .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
22043 .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
22044 .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
22045 .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
22046 .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
22047 .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
22048 .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;}
22049 .ac-badge.active{opacity:1;}
22050 .ac-badge.github{border-color:#555;color:#555;}
22051 .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
22052 .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
22053 .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
22054 .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
22055 body.dark-theme .ac-right-row{color:var(--muted);}
22056 body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
22057 @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
22058 .divider{height:1px;background:var(--line);margin:32px 0;}
22059 .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
22060 @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
22061 @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
22062 .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
22063 transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
22064 .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
22065 .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
22066 body.dark-theme .info-chip-val{color:var(--oxide);}
22067 .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
22068 .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
22069 background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
22070 white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
22071 .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
22072 border:6px solid transparent;border-top-color:var(--text);}
22073 .info-chip:hover .info-chip-tip{display:block;}
22074 .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
22075 .chip-slide.fading{filter:blur(5px);opacity:0;}
22076 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22077 .site-footer a{color:var(--muted);}
22078 .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;}
22079 .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
22080 body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
22081 .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
22082 .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;}
22083 .lan-badge.local{background:var(--oxide-2);}
22084 .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
22085 .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);}
22086 body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
22087 .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;}
22088 .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
22089 .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
22090 .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;}
22091 body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
22092 .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;}
22093 .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);}
22094 body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
22095 body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
22096 .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
22097 .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;}
22098 @media (max-height: 1100px) {
22099 .page{padding-top:10px;}
22100 .hero{margin-bottom:10px;}
22101 .hero-logo{width:54px;height:60px;}
22102 .hero-logo-shadow{width:42px;}
22103 .hero-title{font-size:28px;}
22104 .hero-subtitle{font-size:13px;}
22105 .card-sections{gap:12px;margin-bottom:6px;}
22106 .card-section-grid-2,.card-section-grid-3{gap:10px;}
22107 .action-card{padding:8px 15px 8px;}
22108 .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
22109 .action-card-icon svg{width:18px;height:18px;}
22110 .action-card-title{font-size:13px;}
22111 .action-card-desc{font-size:11px;margin-bottom:6px;}
22112 .action-card-cta{font-size:11px;}
22113 .ac-right-row{font-size:11px;}
22114 .divider{margin:14px 0;}
22115 .info-strip{gap:7px;margin-bottom:8px;}
22116 .info-chip{padding:7px 10px;}
22117 .info-chip-val{font-size:13px;}
22118 .info-chip-label{font-size:9px;}
22119 .site-footer{padding:8px 24px;font-size:12px;}
22120 .lan-local-hint{margin-top:8px;}
22121 }
22122 @media (max-height: 850px) {
22123 .page{padding-top:6px;}
22124 .hero{margin-bottom:6px;}
22125 .hero-logo{width:42px;height:46px;}
22126 .hero-title{font-size:22px;}
22127 .hero-subtitle{font-size:12px;}
22128 .card-sections{gap:10px;}
22129 .action-card-desc{margin-bottom:4px;}
22130 .divider{margin:8px 0;}
22131 .info-strip{margin-bottom:6px;}
22132 .lan-local-hint{margin-top:10px;}
22133 }
22134 </style>
22135</head>
22136<body>
22137 <div class="background-watermarks" aria-hidden="true">
22138 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22139 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22140 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22141 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22142 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22143 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22144 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22145 </div>
22146 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22147 <div class="top-nav">
22148 <div class="top-nav-inner">
22149 <a class="brand" href="/">
22150 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
22151 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
22152 </a>
22153 <div class="nav-right">
22154 <a class="nav-pill" href="/" style="background:rgba(255,255,255,0.22);">Home</a>
22155 <div class="nav-dropdown">
22156 <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>
22157 <div class="nav-dropdown-menu">
22158 <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>
22159 </div>
22160 </div>
22161 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
22162 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22163 <div class="nav-dropdown">
22164 <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>
22165 <div class="nav-dropdown-menu">
22166 <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>
22167 </div>
22168 </div>
22169 <div class="server-status-wrap" id="server-status-wrap">
22170 <div class="nav-pill server-online-pill" id="server-status-pill">
22171 <span class="status-dot" id="status-dot"></span>
22172 <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
22173 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22174 </div>
22175 <div class="server-status-tip">
22176 {% if server_mode %}OxideSLOC is running in server mode — accessible on your LAN.{% else %}OxideSLOC is running locally — only accessible from this machine.{% endif %}
22177 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22178 </div>
22179 </div>
22180 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22181 <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>
22182 </button>
22183 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
22184 <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>
22185 <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>
22186 </button>
22187 </div>
22188 </div>
22189 </div>
22190
22191 <div class="page">
22192 <div class="hero">
22193 <div class="hero-logo-wrap" id="hero-logo-wrap">
22194 <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
22195 </div>
22196 <div class="hero-logo-shadow"></div>
22197 <div class="hero-title-wrap">
22198 <div class="hero-title-aura" aria-hidden="true"></div>
22199 <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
22200 </div>
22201 <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>
22202 </div>
22203
22204 <div class="card-sections">
22205
22206 <div>
22207 <div class="card-section-label">Analysis</div>
22208 <div class="card-section-grid-2">
22209 <a class="action-card scan card-split" href="/scan-setup">
22210 <div class="action-card-left">
22211 <div class="action-card-icon">
22212 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
22213 </div>
22214 <div class="action-card-title">Scan Project</div>
22215 <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>
22216 <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>
22217 </div>
22218 <div class="action-card-sep"></div>
22219 <div class="action-card-right">
22220 <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>
22221 <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>
22222 <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>
22223 <div class="ac-right-stat" id="acp-scan-stat"></div>
22224 </div>
22225 </a>
22226 <a class="action-card test-metrics card-split" href="/test-metrics">
22227 <div class="action-card-left">
22228 <div class="action-card-icon">
22229 <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>
22230 </div>
22231 <div class="action-card-title">Test Metrics</div>
22232 <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>
22233 <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>
22234 </div>
22235 <div class="action-card-sep"></div>
22236 <div class="action-card-right">
22237 <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>
22238 <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>
22239 <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>
22240 <div class="ac-right-stat" id="acp-test-stat"></div>
22241 </div>
22242 </a>
22243 </div>
22244 </div>
22245
22246 <div>
22247 <div class="card-section-label">Reports & Insights</div>
22248 <div class="card-section-grid-3">
22249 <a class="action-card view" href="/view-reports">
22250 <div class="action-card-icon">
22251 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
22252 </div>
22253 <div class="action-card-title">View Reports</div>
22254 <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
22255 <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>
22256 </a>
22257 <a class="action-card compare" href="/compare-scans">
22258 <div class="action-card-icon">
22259 <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>
22260 </div>
22261 <div class="action-card-title">Compare Scans</div>
22262 <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>
22263 <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>
22264 </a>
22265 <a class="action-card trend" href="/trend-reports">
22266 <div class="action-card-icon">
22267 <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>
22268 </div>
22269 <div class="action-card-title">Trend Report</div>
22270 <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
22271 <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>
22272 </a>
22273 </div>
22274 </div>
22275
22276 <div>
22277 <div class="card-section-label">Developer Tools</div>
22278 <div class="card-section-grid-2">
22279 <a class="action-card git-tools card-split" href="/git-browser">
22280 <div class="action-card-left">
22281 <div class="action-card-icon">
22282 <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>
22283 </div>
22284 <div class="action-card-title">Git Browser</div>
22285 <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>
22286 <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>
22287 </div>
22288 <div class="action-card-sep"></div>
22289 <div class="action-card-right">
22290 <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>
22291 <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>
22292 <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>
22293 </div>
22294 </a>
22295 <a class="action-card automation card-split" href="/integrations">
22296 <div class="action-card-left">
22297 <div class="action-card-icon">
22298 <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>
22299 </div>
22300 <div class="action-card-title">Integrations</div>
22301 <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>
22302 <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>
22303 </div>
22304 <div class="action-card-sep"></div>
22305 <div class="action-card-right">
22306 <div class="ac-badges-grid">
22307 <span class="ac-badge github" id="acp-gh">GitHub</span>
22308 <span class="ac-badge gitlab" id="acp-gl">GitLab</span>
22309 <span class="ac-badge bitbucket" id="acp-bb">Bitbucket</span>
22310 <span class="ac-badge confluence" id="acp-cf">Confluence</span>
22311 </div>
22312 <div class="ac-right-stat" id="acp-int-stat"></div>
22313 </div>
22314 </a>
22315 </div>
22316 </div>
22317
22318 </div>
22319
22320 {% if server_mode %}
22321 <div class="lan-card server">
22322 <div class="lan-card-header">
22323 <span class="lan-badge">LAN server</span>
22324 Accessible on your network
22325 </div>
22326 {% if let Some(ip) = lan_ip %}
22327 <div class="lan-url-row">
22328 <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
22329 <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
22330 <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>
22331 Copy URL
22332 </button>
22333 </div>
22334 <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>
22335 {% if has_api_key %}
22336 <div class="lan-auth-row">curl -H "Authorization: Bearer $SLOC_API_KEY" http://{{ ip }}:{{ port }}/healthz</div>
22337 {% endif %}
22338 {% else %}
22339 <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>
22340 {% endif %}
22341 </div>
22342 {% endif %}
22343
22344 <div class="divider"></div>
22345
22346 <div class="info-strip">
22347 <div class="info-chip">
22348 <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 48 more</div>
22349 <div class="chip-slide">
22350 <div class="info-chip-val">60</div>
22351 <div class="info-chip-label">Languages</div>
22352 </div>
22353 </div>
22354 <div class="info-chip">
22355 <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
22356 <div class="chip-slide">
22357 <div class="info-chip-val">100%</div>
22358 <div class="info-chip-label">Self-contained</div>
22359 </div>
22360 </div>
22361 <div class="info-chip">
22362 <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
22363 <div class="chip-slide">
22364 <div class="info-chip-val">HTML+PDF</div>
22365 <div class="info-chip-label">Exportable reports</div>
22366 </div>
22367 </div>
22368 <div class="info-chip">
22369 <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
22370 <div class="chip-slide">
22371 <div class="info-chip-val">Webhook</div>
22372 <div class="info-chip-label">3 platforms</div>
22373 </div>
22374 </div>
22375 <div class="info-chip">
22376 <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
22377 <div class="chip-slide">
22378 <div class="info-chip-val">IEEE</div>
22379 <div class="info-chip-label">1045-1992</div>
22380 </div>
22381 </div>
22382 </div>
22383
22384 {% if lan_ip.is_none() %}
22385 <div class="lan-local-hint">
22386 <strong>Want teammates on the same network to access this?</strong><br>
22387 Relaunch in server mode: <code>oxide-sloc serve --server</code> or <code>bash scripts/serve-server.sh</code>
22388 </div>
22389 {% endif %}
22390 </div>
22391
22392 <footer class="site-footer">
22393 local code analysis - metrics, history and reports
22394 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
22395 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22396 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22397 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22398 · <a href="/api-docs" rel="noopener">REST API</a>
22399 </footer>
22400
22401 <script nonce="{{ csp_nonce }}">
22402 (function () {
22403 var storageKey = 'oxide-sloc-theme';
22404 var body = document.body;
22405 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
22406 var toggle = document.getElementById('theme-toggle');
22407 if (toggle) toggle.addEventListener('click', function () {
22408 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
22409 body.classList.toggle('dark-theme', next === 'dark');
22410 try { localStorage.setItem(storageKey, next); } catch(e) {}
22411 });
22412 var copyBtn = document.getElementById('lan-copy-btn');
22413 if (copyBtn) copyBtn.addEventListener('click', function() {
22414 var btn = this;
22415 var el = document.getElementById('lan-url-val');
22416 if (!el) return;
22417 var url = el.textContent.trim();
22418 if (navigator.clipboard) {
22419 navigator.clipboard.writeText(url).then(function() {
22420 var orig = btn.innerHTML;
22421 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!';
22422 setTimeout(function() { btn.innerHTML = orig; }, 1800);
22423 });
22424 }
22425 });
22426 (function randomizeWatermarks() {
22427 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22428 if (!wms.length) return;
22429 var placed = [];
22430 function tooClose(top, left) {
22431 for (var i = 0; i < placed.length; i++) {
22432 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
22433 if (dt < 16 && dl < 12) return true;
22434 }
22435 return false;
22436 }
22437 function pick(leftBand) {
22438 for (var attempt = 0; attempt < 50; attempt++) {
22439 var top = Math.random() * 88 + 2;
22440 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22441 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
22442 }
22443 var top = Math.random() * 88 + 2;
22444 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
22445 placed.push([top, left]); return [top, left];
22446 }
22447 var half = Math.floor(wms.length / 2);
22448 wms.forEach(function (img, i) {
22449 var pos = pick(i < half);
22450 var size = Math.floor(Math.random() * 100 + 120);
22451 var rot = (Math.random() * 360).toFixed(1);
22452 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
22453 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;
22454 });
22455 })();
22456
22457 (function spawnCodeParticles() {
22458 var container = document.getElementById('code-particles');
22459 if (!container) return;
22460 var snippets = [
22461 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
22462 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
22463 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
22464 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
22465 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
22466 ];
22467 var count = 38;
22468 for (var i = 0; i < count; i++) {
22469 (function(idx) {
22470 var el = document.createElement('span');
22471 el.className = 'code-particle';
22472 var text = snippets[idx % snippets.length];
22473 el.textContent = text;
22474 var left = Math.random() * 94 + 2;
22475 var top = Math.random() * 88 + 6;
22476 var dur = (Math.random() * 10 + 9).toFixed(1);
22477 var delay = (Math.random() * 18).toFixed(1);
22478 var rot = (Math.random() * 26 - 13).toFixed(1);
22479 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
22480 el.style.left=left.toFixed(1)+'%';el.style.top=top.toFixed(1)+'%';
22481 + '--rot:' + rot + 'deg;--op:' + op + ';'
22482 + 'animation-duration:' + dur + 's;animation-delay:-' + delay + 's;';
22483 container.appendChild(el);
22484 })(i);
22485 }
22486 })();
22487 (function heroAnimations() {
22488 var sub = document.getElementById('hero-subtitle');
22489 if (sub) {
22490 var full = sub.textContent.trim();
22491 sub.textContent = '';
22492 sub.style.opacity = '1';
22493 var cursor = document.createElement('span');
22494 cursor.className = 'hero-cursor';
22495 sub.appendChild(cursor);
22496 var i = 0;
22497 setTimeout(function() {
22498 var iv = setInterval(function() {
22499 if (i < full.length) {
22500 sub.insertBefore(document.createTextNode(full[i]), cursor);
22501 i++;
22502 } else {
22503 clearInterval(iv);
22504 setTimeout(function() {
22505 cursor.style.transition = 'opacity 1s ease';
22506 cursor.style.opacity = '0';
22507 setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
22508 }, 2400);
22509 }
22510 }, 11);
22511 }, 374);
22512 }
22513 })();
22514 (function logoBob() {
22515 var logo = document.querySelector('.hero-logo');
22516 var shadow = document.querySelector('.hero-logo-shadow');
22517 if (!logo) return;
22518 var cycleStart = null, cycleDur = 3600;
22519 var peakY = -14, peakScale = 1.07, peakRot = 0;
22520 function newCycle() {
22521 cycleDur = 3000 + Math.random() * 1840;
22522 peakY = -(9 + Math.random() * 13.8);
22523 peakScale = 1.04 + Math.random() * 0.081;
22524 peakRot = (Math.random() * 11.5 - 5.75);
22525 }
22526 function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
22527 newCycle();
22528 function frame(ts) {
22529 if (cycleStart === null) cycleStart = ts;
22530 var t = (ts - cycleStart) / cycleDur;
22531 if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
22532 var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
22533 var y = peakY * phase;
22534 var sc = 1 + (peakScale - 1) * phase;
22535 var rot = peakRot * Math.sin(Math.PI * phase);
22536 logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
22537 if (shadow) {
22538 shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
22539 shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
22540 }
22541 requestAnimationFrame(frame);
22542 }
22543 requestAnimationFrame(frame);
22544 })();
22545 (function mouseEffects() {
22546 var heroTitle = document.getElementById('hero-title');
22547 var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
22548 function tick() {
22549 raf = null;
22550 if (heroTitle) {
22551 var r = heroTitle.getBoundingClientRect();
22552 var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
22553 var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
22554 heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
22555 }
22556 }
22557 document.addEventListener('mousemove', function(e) {
22558 mx = e.clientX; my = e.clientY;
22559 if (!raf) raf = requestAnimationFrame(tick);
22560 });
22561 document.addEventListener('mouseleave', function() {
22562 if (heroTitle) {
22563 heroTitle.style.transition = 'transform 0.5s ease';
22564 heroTitle.style.transform = '';
22565 setTimeout(function() { heroTitle.style.transition = ''; }, 500);
22566 }
22567 });
22568 document.querySelectorAll('.action-card').forEach(function(card) {
22569 card.addEventListener('mousemove', function(e) {
22570 var rect = card.getBoundingClientRect();
22571 var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
22572 var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
22573 card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
22574 card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
22575 });
22576 card.addEventListener('mouseleave', function() {
22577 card.style.transition = '';
22578 card.style.transform = '';
22579 });
22580 });
22581 })();
22582 (function chipSlideshow() {
22583 var slides = [
22584 [{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'}],
22585 [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
22586 [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
22587 [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
22588 [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
22589 ];
22590 var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
22591 var indices = [0,0,0,0,0];
22592 var paused = [false,false,false,false,false];
22593 chips.forEach(function(chip, i) {
22594 chip.addEventListener('mouseenter', function() { paused[i] = true; });
22595 chip.addEventListener('mouseleave', function() { paused[i] = false; });
22596 });
22597 function advance(i) {
22598 if (paused[i]) return;
22599 var chip = chips[i];
22600 var inner = chip.querySelector('.chip-slide');
22601 if (!inner) return;
22602 inner.classList.add('fading');
22603 setTimeout(function() {
22604 indices[i] = (indices[i] + 1) % slides[i].length;
22605 var s = slides[i][indices[i]];
22606 chip.querySelector('.info-chip-val').textContent = s.v;
22607 chip.querySelector('.info-chip-label').textContent = s.l;
22608 inner.classList.remove('fading');
22609 }, 720);
22610 }
22611 setInterval(function() {
22612 chips.forEach(function(chip, i) { advance(i); });
22613 }, 6000);
22614 })();
22615 (function cardLiveData() {
22616 fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
22617 var el = document.getElementById('acp-scan-stat');
22618 if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
22619 }).catch(function(){});
22620 fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
22621 var el = document.getElementById('acp-test-stat');
22622 if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
22623 }).catch(function(){});
22624 fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
22625 var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
22626 var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
22627 if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
22628 if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
22629 if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
22630 var stat = document.getElementById('acp-int-stat');
22631 if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
22632 }).catch(function(){});
22633 fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
22634 if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
22635 }).catch(function(){});
22636 })();
22637 })();
22638 </script>
22639 <script nonce="{{ csp_nonce }}">
22640 (function(){
22641 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'}];
22642 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);});}
22643 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
22644 function init(){
22645 var btn=document.getElementById('settings-btn');if(!btn)return;
22646 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
22647 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>';
22648 document.body.appendChild(m);
22649 var g=document.getElementById('scheme-grid');
22650 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);});
22651 var cl=document.getElementById('settings-close');
22652 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);});})();
22653 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');});
22654 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
22655 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
22656 }
22657 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
22658 }());
22659 </script>
22660 <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>
22661</body>
22662</html>
22663"##,
22664 ext = "html"
22665)]
22666struct SplashTemplate {
22667 csp_nonce: String,
22668 server_mode: bool,
22669 lan_ip: Option<String>,
22670 port: u16,
22671 version: &'static str,
22672 has_api_key: bool,
22673}
22674
22675#[derive(Template)]
22678#[template(
22679 source = r##"
22680<!doctype html>
22681<html lang="en">
22682<head>
22683 <meta charset="utf-8">
22684 <meta name="viewport" content="width=device-width, initial-scale=1">
22685 <title>OxideSLOC — Start a Scan</title>
22686 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
22687 <style nonce="{{ csp_nonce }}">
22688 :root {
22689 --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
22690 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
22691 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
22692 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
22693 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
22694 }
22695 body.dark-theme {
22696 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
22697 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
22698 }
22699 *{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;}
22700 .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);}
22701 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
22702 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
22703 .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));}
22704 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
22705 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
22706 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
22707 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
22708 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
22709 @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; } }
22710 .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;}
22711 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
22712 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
22713 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
22714 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
22715 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
22716 .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;}
22717 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
22718 .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);}
22719 .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;}
22720 .settings-close:hover{color:var(--text);background:var(--surface-2);}
22721 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
22722 .settings-modal-body{padding:14px 16px 16px;}
22723 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
22724 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
22725 .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;}
22726 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
22727 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
22728 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
22729 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
22730 .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;}
22731 .tz-select:focus{border-color:var(--oxide);}
22732 .page{max-width:1104px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
22733 .page-header{text-align:center;margin-bottom:16px;}
22734 .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
22735 .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
22736 /* Cards */
22737 .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
22738 .option-card-wrap{position:relative;}
22739 .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;}
22740 .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
22741 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
22742 @media(prefers-reduced-motion:reduce){.option-card{animation:none;}}
22743 .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;}
22744 .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
22745 .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
22746 #recent-card{flex-direction:column;align-items:stretch;gap:0;}
22747 .card-top-row{display:flex;align-items:center;gap:20px;}
22748 /* Two-column layout inside each card */
22749 .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
22750 .card-left{display:flex;align-items:flex-start;min-width:0;}
22751 .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
22752 .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
22753 .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);}
22754 .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);}
22755 .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);}
22756 .card-text{min-width:0;}
22757 .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
22758 .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
22759 .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
22760 .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
22761 .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
22762 /* Right CTA column */
22763 .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
22764 .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;}
22765 /* Re-scan count badge */
22766 .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
22767 .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
22768 .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
22769 body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
22770 .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
22771 .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
22772 .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
22773 body.dark-theme .btn-secondary{color:var(--oxide);}
22774 .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
22775 .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
22776 /* File input overlay — must be full-width so it aligns with other card-right buttons */
22777 .file-input-wrap{position:relative;width:100%;}
22778 .file-input-wrap .btn{width:100%;}
22779 .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
22780 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22781 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
22782 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
22783 .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;}
22784 @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));}}
22785 /* Recent list (card 3 — full-width section below header) */
22786 .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
22787 .recent-list{display:flex;flex-direction:column;gap:8px;}
22788 .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;}
22789 .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
22790 .recent-item-info{flex:1;min-width:0;}
22791 .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
22792 .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
22793 .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
22794 .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
22795 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
22796 .site-footer a{color:var(--muted);}
22797 @media(max-width:680px){
22798 .card-body{grid-template-columns:1fr;}
22799 .card-right{flex-direction:row;flex-wrap:wrap;}
22800 .btn{flex:1;}
22801 }
22802 .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;}
22803 .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;}
22804 .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;}
22805 </style>
22806</head>
22807<body>
22808 <div class="background-watermarks" aria-hidden="true">
22809 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22810 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22811 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22812 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22813 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22814 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22815 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
22816 </div>
22817 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
22818 <div class="top-nav">
22819 <div class="top-nav-inner">
22820 <a class="brand" href="/">
22821 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
22822 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
22823 </a>
22824 <div class="nav-right">
22825 <a class="nav-pill" href="/">Home</a>
22826 <div class="nav-dropdown">
22827 <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>
22828 <div class="nav-dropdown-menu">
22829 <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>
22830 </div>
22831 </div>
22832 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
22833 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
22834 <div class="nav-dropdown">
22835 <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>
22836 <div class="nav-dropdown-menu">
22837 <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>
22838 </div>
22839 </div>
22840 <div class="server-status-wrap" id="server-status-wrap">
22841 <div class="nav-pill server-online-pill" id="server-status-pill">
22842 <span class="status-dot" id="status-dot"></span>
22843 <span id="server-status-label">Server</span>
22844 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
22845 </div>
22846 <div class="server-status-tip">
22847 OxideSLOC is running — accessible on your network.
22848 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
22849 </div>
22850 </div>
22851 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
22852 <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>
22853 </button>
22854 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
22855 <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>
22856 <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>
22857 </button>
22858 </div>
22859 </div>
22860 </div>
22861
22862 <div class="page">
22863 <div class="page-header">
22864 <h1>How would you like to scan?</h1>
22865 <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
22866 </div>
22867
22868 <div class="option-grid">
22869
22870 <!-- Option 1: New scan -->
22871 <div class="option-card-wrap">
22872 <div class="option-card">
22873 <div class="option-icon new-scan">
22874 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
22875 </div>
22876 <div class="card-body">
22877 <div class="card-left">
22878 <div class="card-text">
22879 <div class="option-title">Start a new scan</div>
22880 <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>
22881 <ul class="feature-list">
22882 <li>Live project scope preview before you run</li>
22883 <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
22884 <li>HTML, PDF, and JSON output — your choice</li>
22885 </ul>
22886 </div>
22887 </div>
22888 <div class="card-right">
22889 <a class="btn btn-primary" href="/scan">
22890 Configure & scan
22891 <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
22892 </a>
22893 <p class="card-tip">Full 4-step setup · all options</p>
22894 </div>
22895 </div>
22896 </div>
22897 </div>
22898
22899 <!-- Option 2: Load from config file -->
22900 <div class="option-card-wrap">
22901 <div class="option-card">
22902 <div class="option-icon load-config">
22903 <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>
22904 </div>
22905 <div class="card-body">
22906 <div class="card-left">
22907 <div class="card-text">
22908 <div class="option-title">Load a saved config</div>
22909 <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>
22910 <ul class="feature-list">
22911 <li>All 15 settings restored from the file</li>
22912 <li>Fully editable — change path or output dir</li>
22913 <li>Works with any scan-config.json</li>
22914 </ul>
22915 </div>
22916 </div>
22917 <div class="card-right">
22918 <div class="file-input-wrap">
22919 <button class="btn btn-secondary" id="load-config-btn" type="button">
22920 <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>
22921 Choose config file
22922 </button>
22923 <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
22924 </div>
22925 <p class="card-tip" id="config-file-name">Exported after every scan</p>
22926 </div>
22927 </div>
22928 </div>
22929 </div>
22930
22931 <!-- Option 3: Re-scan recent project -->
22932 <div class="option-card-wrap">
22933 <div class="option-card" id="recent-card">
22934 <div class="card-top-row">
22935 <div class="option-icon rescan">
22936 <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>
22937 </div>
22938 <div class="card-body">
22939 <div class="card-left">
22940 <div class="card-text">
22941 <div class="option-title">Re-scan a recent project</div>
22942 <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>
22943 <ul class="feature-list">
22944 <li>All 15+ settings restored from the saved config</li>
22945 <li>Path and output dir are editable before running</li>
22946 <li>Only scans with a saved config appear here</li>
22947 </ul>
22948 </div>
22949 </div>
22950 <div class="card-right">
22951 <div class="rescan-count-box">
22952 <div class="rescan-count-num" id="rescan-count-num">—</div>
22953 <div class="rescan-count-label">saved configs</div>
22954 </div>
22955 <a class="btn btn-secondary" href="/view-reports">
22956 <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>
22957 View all runs
22958 </a>
22959 <p class="card-tip">Opens run history</p>
22960 </div>
22961 </div>
22962 </div>
22963 <div class="section-divider"></div>
22964 <div class="recent-list" id="recent-list">
22965 <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
22966 </div>
22967 </div>
22968 </div>
22969
22970 </div>
22971 </div>
22972
22973 <footer class="site-footer">
22974 local code analysis - metrics, history and reports
22975 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
22976 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
22977 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
22978 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
22979 · <a href="/api-docs" rel="noopener">REST API</a>
22980 </footer>
22981
22982 <script nonce="{{ csp_nonce }}">
22983 (function () {
22984 var storageKey = 'oxide-sloc-theme';
22985 var body = document.body;
22986 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
22987 var toggle = document.getElementById('theme-toggle');
22988 if (toggle) toggle.addEventListener('click', function () {
22989 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
22990 body.classList.toggle('dark-theme', next === 'dark');
22991 try { localStorage.setItem(storageKey, next); } catch(e) {}
22992 });
22993
22994 (function randomizeWatermarks() {
22995 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
22996 if (!wms.length) return;
22997 var placed = [];
22998 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; }
22999 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]; }
23000 var half = Math.floor(wms.length / 2);
23001 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; });
23002 })();
23003 (function spawnCodeParticles() {
23004 var container = document.getElementById('code-particles');
23005 if (!container) return;
23006 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'];
23007 var count = 38;
23008 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); }
23009 })();
23010 // Recent scans data injected from server
23011 var recentScans = {{ recent_scans_json|safe }};
23012
23013 function configToParams(cfg) {
23014 var p = new URLSearchParams();
23015 p.set('prefilled', '1');
23016 if (cfg.path) p.set('path', cfg.path);
23017 if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
23018 if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
23019 if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
23020 p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
23021 p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
23022 p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
23023 p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
23024 p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
23025 if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
23026 p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
23027 if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
23028 if (cfg.report_title) p.set('report_title', cfg.report_title);
23029 p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
23030 if (cfg.generate_pdf) p.set('generate_pdf', 'on');
23031 if (cfg.continuation_line_policy) p.set('continuation_line_policy', cfg.continuation_line_policy);
23032 if (cfg.blank_in_block_comment_policy) p.set('blank_in_block_comment_policy', cfg.blank_in_block_comment_policy);
23033 p.set('count_compiler_directives', cfg.count_compiler_directives === false ? 'disabled' : 'enabled');
23034 p.set('style_analysis_enabled', cfg.style_analysis_enabled === false ? 'disabled' : 'enabled');
23035 if (cfg.style_col_threshold) p.set('style_col_threshold', String(cfg.style_col_threshold));
23036 if (cfg.style_score_threshold) p.set('style_score_threshold', String(cfg.style_score_threshold));
23037 if (cfg.style_lang_scope) p.set('style_lang_scope', cfg.style_lang_scope);
23038 if (cfg.coverage_file) p.set('coverage_file', cfg.coverage_file);
23039 if (cfg.cocomo_mode) p.set('cocomo_mode', cfg.cocomo_mode);
23040 if (cfg.complexity_alert) p.set('complexity_alert', String(cfg.complexity_alert));
23041 if (cfg.activity_window !== undefined && cfg.activity_window !== null) p.set('activity_window', String(cfg.activity_window));
23042 if (cfg.exclude_duplicates) p.set('exclude_duplicates', 'enabled');
23043 return p;
23044 }
23045
23046 // Build recent scan list (capped at 3 visible entries)
23047 var list = document.getElementById('recent-list');
23048 var noNote = document.getElementById('no-recent-note');
23049 var hasAny = false;
23050 var MAX_RECENT = 3;
23051 if (Array.isArray(recentScans)) {
23052 var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
23053 var shown = 0;
23054 validEntries.forEach(function (entry) {
23055 if (shown >= MAX_RECENT) return;
23056 shown++;
23057 hasAny = true;
23058 var item = document.createElement('div');
23059 item.className = 'recent-item';
23060 item.title = 'Restore all settings and open wizard';
23061 item.innerHTML =
23062 '<div class="recent-item-info">' +
23063 '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
23064 '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' \u00b7 ' + escHtml(entry.timestamp || '') + '</div>' +
23065 '</div>' +
23066 '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
23067 item.addEventListener('click', function () {
23068 var params = configToParams(entry.config);
23069 window.location.href = '/scan?' + params.toString();
23070 });
23071 list.appendChild(item);
23072 });
23073 if (validEntries.length > MAX_RECENT) {
23074 var moreEl = document.createElement('div');
23075 moreEl.className = 'recent-more-link';
23076 moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more — <a href="/view-reports">view all runs</a>';
23077 list.appendChild(moreEl);
23078 }
23079 }
23080 if (hasAny && noNote) noNote.style.display = 'none';
23081 // Update count badge
23082 var countEl = document.getElementById('rescan-count-num');
23083 if (countEl) {
23084 var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
23085 countEl.textContent = total > 0 ? total : '0';
23086 }
23087
23088 // Config file loader
23089 var fileInput = document.getElementById('config-file-input');
23090 var fileName = document.getElementById('config-file-name');
23091 var loadBtn = document.getElementById('load-config-btn');
23092 // Wire the visible button to open the hidden file picker.
23093 if (loadBtn && fileInput) {
23094 loadBtn.addEventListener('click', function () { fileInput.click(); });
23095 }
23096 if (fileInput) {
23097 fileInput.addEventListener('change', function () {
23098 var file = fileInput.files && fileInput.files[0];
23099 if (!file) return;
23100 if (fileName) fileName.textContent = '\u2713 ' + file.name;
23101 var reader = new FileReader();
23102 reader.onload = function (e) {
23103 try {
23104 var cfg = JSON.parse(e.target.result);
23105 if (!cfg || typeof cfg !== 'object') { alert('Invalid config file \u2014 expected a JSON object.'); return; }
23106 var params = configToParams(cfg);
23107 window.location.href = '/scan?' + params.toString();
23108 } catch (err) {
23109 alert('Could not parse config file: ' + err.message);
23110 }
23111 };
23112 reader.readAsText(file);
23113 });
23114 }
23115
23116 function escHtml(s) {
23117 return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
23118 }
23119 })();
23120 </script>
23121 <script nonce="{{ csp_nonce }}">
23122 (function(){
23123 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'}];
23124 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);});}
23125 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
23126 function init(){
23127 var btn=document.getElementById('settings-btn');if(!btn)return;
23128 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
23129 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>';
23130 document.body.appendChild(m);
23131 var g=document.getElementById('scheme-grid');
23132 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);});
23133 var cl=document.getElementById('settings-close');
23134 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);});})();
23135 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');});
23136 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
23137 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
23138 }
23139 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
23140 }());
23141 </script>
23142 <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]';
23143 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;}
23144 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>
23145</body>
23146</html>
23147"##,
23148 ext = "html"
23149)]
23150struct ScanSetupTemplate {
23151 version: &'static str,
23152 recent_scans_json: String,
23153 csp_nonce: String,
23154}
23155
23156#[derive(Template)]
23157#[template(
23158 source = r##"
23159<!doctype html>
23160<html lang="en">
23161<head>
23162 <meta charset="utf-8">
23163 <meta name="viewport" content="width=device-width, initial-scale=1">
23164 <title>OxideSLOC | {{ report_title }} | Report</title>
23165 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
23166 <style nonce="{{ csp_nonce }}">
23167 :root {
23168 --radius: 18px;
23169 --bg: #f5efe8;
23170 --surface: rgba(255,255,255,0.82);
23171 --surface-2: #fbf7f2;
23172 --surface-3: #efe6dc;
23173 --line: #e6d0bf;
23174 --line-strong: #dcb89f;
23175 --text: #43342d;
23176 --muted: #7b675b;
23177 --muted-2: #a08777;
23178 --nav: #b85d33;
23179 --nav-2: #7a371b;
23180 --accent: #6f9bff;
23181 --accent-2: #4a78ee;
23182 --oxide: #d37a4c;
23183 --oxide-2: #b35428;
23184 --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
23185 --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
23186 --success-bg: #e8f5ed;
23187 --success-text: #1a8f47;
23188 --info-bg: #eef3ff;
23189 --info-text: #4467d8;
23190 }
23191
23192 body.dark-theme {
23193 --bg: #1b1511;
23194 --surface: #261c17;
23195 --surface-2: #2d221d;
23196 --surface-3: #372922;
23197 --line: #524238;
23198 --line-strong: #6c5649;
23199 --text: #f5ece6;
23200 --muted: #c7b7aa;
23201 --muted-2: #aa9485;
23202 --nav: #b85d33;
23203 --nav-2: #7a371b;
23204 --accent: #6f9bff;
23205 --accent-2: #4a78ee;
23206 --oxide: #d37a4c;
23207 --oxide-2: #b35428;
23208 --shadow: 0 18px 42px rgba(0,0,0,0.28);
23209 --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
23210 --success-bg: #163927;
23211 --success-text: #8fe2a8;
23212 --info-bg: #1c2847;
23213 --info-text: #a9c1ff;
23214 }
23215
23216 * { box-sizing: border-box; }
23217 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); }
23218 body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
23219 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
23220 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
23221 .top-nav, .page { position: relative; z-index: 2; }
23222 .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); }
23223 .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; }
23224 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
23225 .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)); }
23226 .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; }
23227 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
23228 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
23229 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
23230 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
23231 .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; }
23232 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
23233 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
23234 .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
23235 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
23236 @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; } }
23237 .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; }
23238 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
23239 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
23240 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
23241 .theme-toggle .icon-sun { display:none; }
23242 body.dark-theme .theme-toggle .icon-sun { display:block; }
23243 body.dark-theme .theme-toggle .icon-moon { display:none; }
23244 .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;}
23245 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
23246 .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);}
23247 .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;}
23248 .settings-close:hover{color:var(--text);background:var(--surface-2);}
23249 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
23250 .settings-modal-body{padding:14px 16px 16px;}
23251 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
23252 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
23253 .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;}
23254 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
23255 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
23256 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
23257 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
23258 .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;}
23259 .tz-select:focus{border-color:var(--oxide);}
23260 .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; }
23261 .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;}
23262 .page { width: 100%; max-width: 1720px; margin: 0 auto; padding: 32px 24px 36px; }
23263 .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
23264 .hero, .panel { padding: 22px; }
23265 .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
23266 .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
23267 .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
23268 .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
23269 .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; }
23270 .compare-banner-body { display:flex; flex-direction:column; gap: 10px; }
23271 .compare-banner-top { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
23272 .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; }
23273 .compare-banner-actions-left { display:flex; gap:8px; flex-wrap:wrap; }
23274 .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
23275 .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
23276 .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
23277 .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
23278 .delta-cards-inline { display:grid; grid-template-columns:repeat(7,1fr); gap:8px; flex:1 1 auto; }
23279 .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); }
23280 .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
23281 .delta-card-val { font-size:16px; font-weight:800; }
23282 .delta-card-val.pos { color:#1e7e34; }
23283 .delta-card-val.neg { color:var(--neg); }
23284 .delta-card-val.mod { color:#b35428; }
23285 .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
23286 .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; }
23287 .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
23288 .delta-card-inline:hover .delta-card-tip { opacity:1; transform:translateX(-50%) translateY(0); }
23289 .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
23290 .compare-ts { font-size:13px; color:var(--muted); }
23291 .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
23292 .compare-arrow { color: var(--muted); }
23293 .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
23294 .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; }
23295 .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
23296 .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
23297 .run-mgmt-strip { display:flex; flex-wrap:wrap; gap:14px; align-items:stretch; margin-top:18px; }
23298 .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; }
23299 .run-mgmt-card h3 { margin:0 0 4px; font-size:14px; font-weight:800; }
23300 .run-mgmt-card .action-buttons { justify-content:center; }
23301 .run-mgmt-card .action-empty-note { font-size:11px; color:var(--muted); margin:0; text-align:center; }
23302 body.dark-theme .run-mgmt-card { background:var(--surface-2); border-color:var(--line); }
23303 .button, .copy-button {
23304 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;
23305 }
23306 .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
23307 @keyframes spin { to { transform: rotate(360deg); } }
23308 .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
23309 .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
23310 .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
23311 .path-item strong { display: block; margin-bottom: 6px; }
23312 .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
23313 .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
23314 .path-subitem { flex: 1; }
23315 .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); }
23316 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); }
23317 .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
23318 table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
23319 th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
23320 .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
23321 th { color: var(--muted); font-weight: 700; }
23322 tr:last-child td { border-bottom: none; }
23323 #subm-tbl col:nth-child(1){width:15%;}
23324 #subm-tbl col:nth-child(2){width:31%;}
23325 #subm-tbl col:nth-child(3){width:9%;}
23326 #subm-tbl col:nth-child(4){width:9%;}
23327 #subm-tbl col:nth-child(5){width:9%;}
23328 #subm-tbl col:nth-child(6){width:9%;}
23329 #subm-tbl col:nth-child(7){width:9%;}
23330 #subm-tbl col:nth-child(8){width:9%;}
23331 .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
23332 iframe { width: 100%; min-height: 1000px; border: none; background: white; }
23333 .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
23334 .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
23335 .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
23336 .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
23337 .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; }
23338 .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; }
23339 .soft-chip.success svg { flex:0 0 auto; opacity:0.75; }
23340 body.dark-theme .soft-chip.success { background:rgba(143,226,168,0.07); border-color:rgba(143,226,168,0.18); }
23341 .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
23342 .muted { color: var(--muted); }
23343 /* Run-ID chip row (mirrors HTML report) */
23344 .run-id-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:14px; }
23345 @media(max-width:960px) { .run-id-row { grid-template-columns:1fr 1fr; } }
23346 @media(max-width:560px) { .run-id-row { grid-template-columns:1fr; } }
23347 .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; }
23348 .run-id-chip[data-copy] { cursor:pointer; }
23349 a.run-id-chip { text-decoration:none; cursor:pointer; }
23350 .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
23351 .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
23352 .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; }
23353 .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
23354 .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
23355 .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
23356 .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
23357 a.commit-link-value { color:inherit; text-decoration:none; }
23358 a.commit-link-value:hover { color:var(--accent); text-decoration:underline; }
23359 .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; }
23360 .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
23361 .run-id-chip:hover .chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
23362 .chip-label-icon { display:inline-block; vertical-align:middle; opacity:0.8; flex:0 0 auto; }
23363 .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; }
23364 body.dark-theme .run-id-short-badge { color:var(--muted-2); }
23365 @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
23366 .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
23367 /* Meta chips row */
23368 .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%; }
23369 .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; }
23370 .meta-chip:last-child { border-right:none; }
23371 .meta-chip b { color:var(--text); font-weight:700; }
23372 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
23373 .site-footer a{color:var(--muted);}
23374 .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; }
23375 .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
23376 .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; }
23377 .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
23378 /* Stat chips (matches HTML report) */
23379 .summary-strip { display:grid; grid-template-columns:repeat(8,1fr); gap:10px; margin-top:18px; }
23380 @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
23381 /* Hero stat strip: uniform grid where every card is the same width and the
23382 columns line up across both rows. JS sets the column count to ceil(n/2) so
23383 the cards always occupy exactly two rows; when the count is odd the last
23384 card spans two columns to fill the trailing cell with no empty gap. */
23385 .summary-strip-hero { align-items:stretch; }
23386 .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; }
23387 .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
23388 .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
23389 .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
23390 .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; }
23391 .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); }
23392 .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
23393 .stat-chip:hover .stat-chip-tip { opacity:1; transform:translateX(-50%) translateY(0); }
23394 .cocomo-box { background:var(--surface); border:1px solid var(--line); border-radius:14px; padding:20px 22px; }
23395 .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; }
23396 .cocomo-box-title { font-size:18px; font-weight:750; color:var(--text); letter-spacing:-0.01em; }
23397 .cocomo-mode-pill-wrap { position:relative; display:inline-flex; align-items:center; cursor:help; }
23398 .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); }
23399 .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); }
23400 .cocomo-mode-tip::before { content:''; position:absolute; bottom:100%; left:14px; border:5px solid transparent; border-bottom-color:var(--text); }
23401 .cocomo-mode-pill-wrap:hover .cocomo-mode-tip { opacity:1; transform:translateY(0); }
23402 .cocomo-box-note { font-size:13px; color:var(--muted); margin-top:10px; line-height:1.6; }
23403 /* Submodule panel */
23404 .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
23405 /* Metrics tables stack */
23406 .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
23407 .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
23408 @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
23409 .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)); }
23410 .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
23411 /* Metrics table */
23412 .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
23413 .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
23414 .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; }
23415 .metrics-table thead th:not(:first-child) { text-align: right; }
23416 .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
23417 .metrics-table tbody tr:last-child td { border-bottom: none; }
23418 .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
23419 .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
23420 .metrics-table tbody tr:hover td { background: var(--surface-2); }
23421 .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
23422 .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; }
23423 .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
23424 .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
23425 .mt-val-pos { color: var(--pos); font-weight: 700; }
23426 .mt-val-neg { color: var(--neg); font-weight: 700; }
23427 .mt-val-zero { color: var(--muted); }
23428 .mt-val-mod { color: var(--oxide-2); }
23429 .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
23430 @media (max-width: 1180px) {
23431 .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
23432 .nav-project-slot, .nav-status { justify-content:flex-start; }
23433 .hero-top { flex-direction: column; }
23434 .run-mgmt-strip { flex-direction: column; }
23435 }
23436 .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;}
23437 @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));}}
23438 .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;}
23439 /* ── Result-page chart controls ─────────────────────────────────────────── */
23440 .r-chart-section{margin-bottom:24px;}
23441 .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
23442 .section-pair > .panel{flex-shrink:0;}
23443 .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
23444 .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;}
23445 .r-chart-select:focus{border-color:var(--accent);}
23446 .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
23447 .r-chart-container svg{display:block;width:100%;height:auto;}
23448 .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;}
23449 .r-expand-btn:hover{background:var(--surface);color:var(--text);}
23450 .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;}
23451 .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);}
23452 .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;}
23453 .r-chart-modal-subtitle{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 12px;display:block;letter-spacing:.02em;}
23454 .r-modal-header{display:flex;align-items:center;gap:12px;flex-wrap:nowrap;margin:0 0 16px;padding-right:44px;}
23455 .r-modal-header .r-chart-modal-title{flex:1 1 auto;margin:0;min-width:0;}
23456 .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;}
23457 .r-chart-modal-close:hover{opacity:.7;}
23458 body.dark-theme .r-chart-modal{background:var(--surface);}
23459 .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;}
23460 .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);}
23461 .lang-bar-row{cursor:pointer;transition:transform .2s cubic-bezier(.34,1.56,.64,1);}
23462 .lang-bar-row:hover{transform:translateY(-2px);}
23463 .lang-bar-row .rchit:hover{filter:none;transform:none;}
23464 .lang-bar-row:hover .rchit{filter:brightness(1.12);transform:scaleY(1.22);}
23465 .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
23466 .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;}
23467 .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
23468 .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
23469 @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
23470 @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
23471 #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;}
23472 .r-lang-overview{display:flex;gap:40px;align-items:center;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
23473 .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
23474 .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;}
23475 .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
23476 @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
23477 .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;}
23478 .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
23479 .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;}
23480 .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;}
23481 body.has-report-banner .top-nav{top:27px;}
23482 body.has-report-banner{padding-bottom:27px;}
23483 </style>
23484</head>
23485<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
23486 <div class="background-watermarks" aria-hidden="true">
23487 <img src="/images/logo/logo-text.png" alt="" />
23488 <img src="/images/logo/logo-text.png" alt="" />
23489 <img src="/images/logo/logo-text.png" alt="" />
23490 <img src="/images/logo/logo-text.png" alt="" />
23491 <img src="/images/logo/logo-text.png" alt="" />
23492 <img src="/images/logo/logo-text.png" alt="" />
23493 <img src="/images/logo/logo-text.png" alt="" />
23494 <img src="/images/logo/logo-text.png" alt="" />
23495 <img src="/images/logo/logo-text.png" alt="" />
23496 <img src="/images/logo/logo-text.png" alt="" />
23497 <img src="/images/logo/logo-text.png" alt="" />
23498 <img src="/images/logo/logo-text.png" alt="" />
23499 <img src="/images/logo/logo-text.png" alt="" />
23500 <img src="/images/logo/logo-text.png" alt="" />
23501 </div>
23502 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
23503 {% if let Some(banner) = report_header_footer %}
23504 <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
23505 {% endif %}
23506 <div class="top-nav">
23507 <div class="top-nav-inner">
23508 <a class="brand" href="/">
23509 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
23510 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
23511 </a>
23512 <div class="nav-project-slot">
23513 <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
23514 </div>
23515 <div class="nav-status">
23516 <a class="nav-pill" href="/" style="text-decoration:none;">Home</a>
23517 <div class="nav-dropdown">
23518 <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>
23519 <div class="nav-dropdown-menu">
23520 <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>
23521 </div>
23522 </div>
23523 <a class="nav-pill" href="/compare-scans" style="text-decoration:none;">Compare Scans</a>
23524 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
23525 <div class="nav-dropdown">
23526 <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>
23527 <div class="nav-dropdown-menu">
23528 <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>
23529 </div>
23530 </div>
23531 <div class="server-status-wrap" id="server-status-wrap">
23532 <div class="nav-pill server-online-pill" id="server-status-pill">
23533 <span class="status-dot" id="status-dot"></span>
23534 <span id="server-status-label">Server</span>
23535 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
23536 </div>
23537 <div class="server-status-tip">
23538 OxideSLOC is running — accessible on your network.
23539 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
23540 </div>
23541 </div>
23542 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
23543 <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>
23544 </button>
23545 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
23546 <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>
23547 <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>
23548 </button>
23549 </div>
23550 </div>
23551 </div>
23552
23553 <div class="page">
23554 <section class="hero">
23555 <div class="hero-top">
23556 <div>
23557 <div style="display:flex;align-items:center;gap:18px;flex-wrap:wrap;">
23558 <h1 class="hero-title" style="margin:0;">{{ report_title }}</h1>
23559 <span class="run-id-short-badge" title="Short run ID — matches the ID shown in View Reports">{{ run_id_short }}</span>
23560 <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>
23561 </div>
23562 </div>
23563 <div class="hero-quick-actions">
23564 {% if server_mode %}
23565 <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>
23566 {% else %}
23567 <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
23568 {% endif %}
23569 <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
23570 {% if !server_mode %}
23571 <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
23572 {% endif %}
23573 <button class="copy-button secondary" id="download-bundle-btn" type="button">Download all artifacts</button>
23574 <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>
23575 </div>
23576 </div>
23577
23578 <!-- Run metadata chips: Run ID · Git Commit · Branch · Last Commit By -->
23579 <div class="run-id-row">
23580 <span class="run-id-chip" data-copy="{{ run_id }}">
23581 <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>
23582 <span class="run-id-chip-value">{{ run_id }}</span>
23583 <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
23584 </span>
23585 {% match git_commit_long %}
23586 {% when Some with (long_sha) %}
23587 {% match git_commit_url %}
23588 {% when Some with (commit_url) %}
23589 <a class="run-id-chip" href="{{ commit_url }}" target="_blank" rel="noopener">
23590 <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>
23591 <span class="run-id-chip-value">{{ long_sha }}</span>
23592 <span class="chip-tooltip">Open commit on version control — click to navigate</span>
23593 </a>
23594 {% when None %}
23595 <span class="run-id-chip" data-copy="{{ long_sha }}">
23596 <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>
23597 <span class="run-id-chip-value">{{ long_sha }}</span>
23598 <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
23599 </span>
23600 {% endmatch %}
23601 {% when None %}
23602 <span class="run-id-chip muted-chip">
23603 <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>
23604 <span class="run-id-chip-value">Not detected</span>
23605 <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
23606 </span>
23607 {% endmatch %}
23608 {% match git_branch %}
23609 {% when Some with (branch) %}
23610 {% match git_branch_url %}
23611 {% when Some with (branch_url) %}
23612 <a class="run-id-chip" href="{{ branch_url }}" target="_blank" rel="noopener">
23613 <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>
23614 <span class="run-id-chip-value">{{ branch }}</span>
23615 <span class="chip-tooltip">Open branch on version control — click to navigate</span>
23616 </a>
23617 {% when None %}
23618 <span class="run-id-chip">
23619 <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>
23620 <span class="run-id-chip-value">{{ branch }}</span>
23621 <span class="chip-tooltip">Git branch active at scan time</span>
23622 </span>
23623 {% endmatch %}
23624 {% when None %}
23625 <span class="run-id-chip muted-chip">
23626 <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>
23627 <span class="run-id-chip-value">Not detected</span>
23628 <span class="chip-tooltip">No Git branch was found for this scan</span>
23629 </span>
23630 {% endmatch %}
23631 {% match git_author %}
23632 {% when Some with (author) %}
23633 <span class="run-id-chip" data-author="{{ author }}">
23634 <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>
23635 <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
23636 <span class="chip-tooltip">Author of the most recent commit at scan time</span>
23637 </span>
23638 {% when None %}
23639 <span class="run-id-chip muted-chip">
23640 <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>
23641 <span class="run-id-chip-value">Not detected</span>
23642 <span class="chip-tooltip">No commit author was found for this scan</span>
23643 </span>
23644 {% endmatch %}
23645 </div>
23646
23647 <!-- Scan metadata row -->
23648 <div class="meta">
23649 <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
23650 <span class="meta-chip">Scanned <b class="ts-local" data-utc-ms="{{ scan_time_utc_ms }}">{{ scan_time_display }}</b></span>
23651 <span class="meta-chip">OS <b>{{ os_display }}</b></span>
23652 <span class="meta-chip">Files analyzed <b>{{ files_analyzed|commas }}</b></span>
23653 <span class="meta-chip">Files skipped <b>{{ files_skipped|commas }}</b></span>
23654 </div>
23655
23656 <!-- All summary stat chips in one unified strip (8 columns) -->
23657 <div class="summary-strip summary-strip-hero">
23658 <div class="stat-chip" data-raw="{{ physical_lines }}">
23659 <div class="stat-chip-label">Physical lines</div>
23660 <div class="stat-chip-val">{{ physical_lines }}</div>
23661 <div class="stat-chip-exact"></div>
23662 <div class="stat-chip-tip">Total lines across all analyzed files, including code, comments, and blank lines.</div>
23663 </div>
23664 <div class="stat-chip" data-raw="{{ code_lines }}">
23665 <div class="stat-chip-label">Code</div>
23666 <div class="stat-chip-val">{{ code_lines }}</div>
23667 <div class="stat-chip-exact"></div>
23668 <div class="stat-chip-tip">Lines containing executable source code, excluding comments and blanks.</div>
23669 </div>
23670 <div class="stat-chip" data-raw="{{ comment_lines }}">
23671 <div class="stat-chip-label">Comments</div>
23672 <div class="stat-chip-val">{{ comment_lines }}</div>
23673 <div class="stat-chip-exact"></div>
23674 <div class="stat-chip-tip">Lines consisting entirely of comments or inline documentation.</div>
23675 </div>
23676 <div class="stat-chip" data-raw="{{ blank_lines }}">
23677 <div class="stat-chip-label">Blank</div>
23678 <div class="stat-chip-val">{{ blank_lines }}</div>
23679 <div class="stat-chip-exact"></div>
23680 <div class="stat-chip-tip">Empty or whitespace-only lines used for readability and spacing.</div>
23681 </div>
23682 <div class="stat-chip" data-raw="{{ mixed_lines }}">
23683 <div class="stat-chip-label">Mixed separate</div>
23684 <div class="stat-chip-val">{{ mixed_lines }}</div>
23685 <div class="stat-chip-exact"></div>
23686 <div class="stat-chip-tip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div>
23687 </div>
23688 <div class="stat-chip" data-raw="{{ functions }}">
23689 <div class="stat-chip-label">Functions</div>
23690 <div class="stat-chip-val">{{ functions }}</div>
23691 <div class="stat-chip-exact"></div>
23692 <div class="stat-chip-tip">Best-effort count of function/method definitions detected across all source files.</div>
23693 </div>
23694 <div class="stat-chip" data-raw="{{ classes }}">
23695 <div class="stat-chip-label">Classes / Types</div>
23696 <div class="stat-chip-val">{{ classes }}</div>
23697 <div class="stat-chip-exact"></div>
23698 <div class="stat-chip-tip">Best-effort count of class, struct, interface, and type definitions.</div>
23699 </div>
23700 <div class="stat-chip" data-raw="{{ variables }}">
23701 <div class="stat-chip-label">Variables</div>
23702 <div class="stat-chip-val">{{ variables }}</div>
23703 <div class="stat-chip-exact"></div>
23704 <div class="stat-chip-tip">Best-effort count of variable and constant declarations.</div>
23705 </div>
23706 <div class="stat-chip" data-raw="{{ imports }}">
23707 <div class="stat-chip-label">Imports</div>
23708 <div class="stat-chip-val">{{ imports }}</div>
23709 <div class="stat-chip-exact"></div>
23710 <div class="stat-chip-tip">Best-effort count of import, include, and module-use statements.</div>
23711 </div>
23712 <div class="stat-chip" data-raw="{{ test_count }}">
23713 <div class="stat-chip-label">Tests</div>
23714 <div class="stat-chip-val">{{ test_count }}</div>
23715 <div class="stat-chip-exact"></div>
23716 <div class="stat-chip-tip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div>
23717 </div>
23718 <div class="stat-chip" data-density data-code="{{ code_lines }}" data-physical="{{ physical_lines }}">
23719 <div class="stat-chip-label">Code density</div>
23720 <div class="stat-chip-val stat-chip-density-val">—</div>
23721 <div class="stat-chip-exact"></div>
23722 <div class="stat-chip-tip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div>
23723 </div>
23724 <div class="stat-chip" data-raw="{{ files_analyzed }}">
23725 <div class="stat-chip-label">Files analyzed</div>
23726 <div class="stat-chip-val">{{ files_analyzed }}</div>
23727 <div class="stat-chip-exact"></div>
23728 <div class="stat-chip-tip">Total number of source files included in this analysis.</div>
23729 </div>
23730 {% if cyclomatic_complexity > 0 %}
23731 <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 %}>
23732 <div class="stat-chip-label">Complexity score</div>
23733 <div class="stat-chip-val">{{ cyclomatic_complexity }}</div>
23734 <div class="stat-chip-exact"></div>
23735 <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>
23736 </div>
23737 {% endif %}
23738 {% if let Some(ls) = lsloc %}
23739 <div class="stat-chip" data-raw="{{ ls }}">
23740 <div class="stat-chip-label">Logical SLOC</div>
23741 <div class="stat-chip-val">{{ ls }}</div>
23742 <div class="stat-chip-exact"></div>
23743 <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>
23744 </div>
23745 {% endif %}
23746 {% if uloc > 0 %}
23747 <div class="stat-chip" data-raw="{{ uloc }}">
23748 <div class="stat-chip-label">Unique SLOC (ULOC)</div>
23749 <div class="stat-chip-val">{{ uloc }}</div>
23750 <div class="stat-chip-exact"></div>
23751 <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>
23752 </div>
23753 {% endif %}
23754 {% if uloc > 0 && dryness_pct_str != "" %}
23755 <div class="stat-chip">
23756 <div class="stat-chip-label">DRYness</div>
23757 <div class="stat-chip-val">{{ dryness_pct_str }}%</div>
23758 <div class="stat-chip-exact"></div>
23759 <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>
23760 </div>
23761 {% endif %}
23762 {% if duplicate_group_count > 0 %}
23763 <div class="stat-chip" data-raw="{{ duplicate_group_count }}" style="border-color:rgba(179,93,51,0.4);">
23764 <div class="stat-chip-label">Duplicate groups</div>
23765 <div class="stat-chip-val">{{ duplicate_group_count }}</div>
23766 <div class="stat-chip-exact"></div>
23767 <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>
23768 </div>
23769 {% endif %}
23770 <!-- Reserve "pad" card: revealed by JS only when the visible card count is
23771 odd, so the strip always forms exactly two full rows with every column
23772 aligned and every card the same width (no oversized card, no gap). -->
23773 <div class="stat-chip stat-chip-pad" data-raw="{{ test_assertion_count }}" style="display:none;">
23774 <div class="stat-chip-label">Assertions</div>
23775 <div class="stat-chip-val">{{ test_assertion_count }}</div>
23776 <div class="stat-chip-exact"></div>
23777 <div class="stat-chip-tip">Best-effort count of test assertion call lines (assertEquals, EXPECT_*, etc.) detected across all test files.</div>
23778 </div>
23779 </div>
23780
23781 {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
23782 <div class="compare-banner">
23783 <div class="compare-banner-body">
23784 <div class="compare-banner-top">
23785 <div class="compare-banner-meta">
23786 <span class="compare-label">Previous scan</span>
23787 <span class="compare-ts">{{ prev_ts }}</span>
23788 {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
23789 {% if let Some(prev_code) = prev_run_code_lines %}
23790 <div class="compare-banner-stats" style="margin-top:4px;">
23791 <span>Code before: <strong data-raw="{{ prev_code }}">{{ prev_code }}</strong></span>
23792 <span class="compare-arrow">→</span>
23793 <span>Code now: <strong data-raw="{{ code_lines }}">{{ code_lines }}</strong></span>
23794 {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+<span data-raw="{{ added }}">{{ added }}</span> added</span>{% endif %}
23795 {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">−<span data-raw="{{ removed }}">{{ removed }}</span> removed</span>{% endif %}
23796 </div>
23797 {% endif %}
23798 </div>
23799 {% if delta_lines_added.is_some() %}
23800 <div class="delta-cards-inline">
23801 <div class="delta-card-inline">
23802 <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v|commas }}{% else %}—{% endif %}</div>
23803 <div class="delta-card-lbl">lines added</div>
23804 <div class="delta-card-tip">Code lines added since the previous scan</div>
23805 </div>
23806 <div class="delta-card-inline">
23807 <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}−{{ v|commas }}{% else %}—{% endif %}</div>
23808 <div class="delta-card-lbl">lines removed</div>
23809 <div class="delta-card-tip">Code lines removed since the previous scan</div>
23810 </div>
23811 <div class="delta-card-inline">
23812 <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v|commas }}{% else %}—{% endif %}</div>
23813 <div class="delta-card-lbl">unmodified lines</div>
23814 <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
23815 </div>
23816 <div class="delta-card-inline">
23817 <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v|commas }}{% else %}—{% endif %}</div>
23818 <div class="delta-card-lbl">files modified</div>
23819 <div class="delta-card-tip">Files with at least one line changed</div>
23820 </div>
23821 <div class="delta-card-inline">
23822 <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v|commas }}{% else %}—{% endif %}</div>
23823 <div class="delta-card-lbl">files added</div>
23824 <div class="delta-card-tip">New files added since the previous scan</div>
23825 </div>
23826 <div class="delta-card-inline">
23827 <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v|commas }}{% else %}—{% endif %}</div>
23828 <div class="delta-card-lbl">files removed</div>
23829 <div class="delta-card-tip">Files deleted since the previous scan</div>
23830 </div>
23831 <div class="delta-card-inline">
23832 <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v|commas }}{% else %}—{% endif %}</div>
23833 <div class="delta-card-lbl">files unchanged</div>
23834 <div class="delta-card-tip">Files with no changes since the previous scan</div>
23835 </div>
23836 <div class="delta-card-inline">
23837 <div class="delta-card-val">{% if let Some(v) = delta_files_total %}{{ v|commas }}{% else %}—{% endif %}</div>
23838 <div class="delta-card-lbl">files total</div>
23839 <div class="delta-card-tip">Total files across both scans (modified + added + removed + unchanged)</div>
23840 </div>
23841 </div>
23842 {% else %}
23843 <p style="font-size:12px;color:var(--muted);line-height:1.5;flex:1;">
23844 Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
23845 </p>
23846 {% endif %}
23847 </div>
23848 <div class="compare-banner-actions">
23849 <div class="compare-banner-actions-left">
23850 <a class="button secondary" href="/runs/result/{{ prev_id }}" style="white-space:nowrap;">View previous report</a>
23851 <a class="button secondary" href="/compare-scans" style="white-space:nowrap;">Compare scans</a>
23852 </div>
23853 <a class="button" href="/compare?a={{ prev_id }}&b={{ run_id }}" style="white-space:nowrap;">Full diff →</a>
23854 </div>
23855 </div>
23856 </div>
23857 {% endif %}{% endif %}
23858
23859 <div class="action-grid">
23860 <div class="action-card">
23861 <h3>HTML report</h3>
23862 <div class="action-buttons">
23863 {% match html_url %}
23864 {% when Some with (url) %}
23865 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
23866 {% when None %}{% endmatch %}
23867 {% match html_download_url %}
23868 {% when Some with (url) %}
23869 <a class="button secondary" href="{{ url }}">Download HTML</a>
23870 {% when None %}{% endmatch %}
23871 {% match html_path %}
23872 {% when Some with (_path) %}{% when None %}{% endmatch %}
23873 <p class="action-empty-note" style="margin-top:6px;">Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
23874 </div>
23875 </div>
23876 <div class="action-card">
23877 <h3>PDF report</h3>
23878 <div class="action-buttons">
23879 {% match pdf_url %}
23880 {% when Some with (url) %}
23881 {% if pdf_generating %}
23882 <button class="button" id="pdf-open-btn" disabled style="opacity:0.55;cursor:not-allowed;gap:8px;">
23883 <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>
23884 Generating PDF…
23885 </button>
23886 {% else %}
23887 <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
23888 {% endif %}
23889 {% when None %}
23890 {% match html_url %}
23891 {% when Some with (_hurl) %}
23892 <a class="button" href="/runs/pdf/{{ run_id }}" target="_blank" rel="noopener" id="pdf-open-btn">Generate PDF</a>
23893 <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>
23894 {% when None %}
23895 <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;">
23896 PDF could not be generated for this run — Chromium or Edge may not be installed. The HTML report is always available above.
23897 </p>
23898 {% endmatch %}
23899 {% endmatch %}
23900 {% match pdf_download_url %}
23901 {% when Some with (url) %}
23902 <a class="button secondary" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} style="opacity:0.55;pointer-events:none;"{% endif %}>Download PDF</a>
23903 {% when None %}{% endmatch %}
23904 {% match pdf_url %}
23905 {% when Some with (_) %}
23906 <p class="action-empty-note" style="margin-top:6px;">Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
23907 {% when None %}{% endmatch %}
23908 </div>
23909 </div>
23910 <div class="action-card">
23911 <h3>JSON result</h3>
23912 <div class="action-buttons">
23913 {% match json_url %}
23914 {% when Some with (url) %}
23915 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
23916 {% when None %}{% endmatch %}
23917 {% match json_download_url %}
23918 {% when Some with (url) %}
23919 <a class="button secondary" href="{{ url }}">Download JSON</a>
23920 {% when None %}{% endmatch %}
23921 {% match json_path %}
23922 {% when Some with (_path) %}
23923 <p class="action-empty-note" style="margin-top:6px;">Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
23924 {% when None %}
23925 <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
23926 {% endmatch %}
23927 </div>
23928 </div>
23929 <div class="action-card">
23930 <h3>Scan config</h3>
23931 <div class="action-buttons">
23932 <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
23933 <a class="button" href="/scan-setup" style="background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;border:none;">Run another scan</a>
23934 <p class="action-empty-note" style="margin-top:6px;">Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
23935 </div>
23936 </div>
23937 {% if confluence_configured %}
23938 <div class="action-card" id="confluenceCard">
23939 <h3>Confluence</h3>
23940 <div class="action-buttons">
23941 <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
23942 <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
23943 </div>
23944 <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>
23945 </div>
23946 {% endif %}
23947 </div>
23948 {% if confluence_configured %}
23949 <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;">
23950 <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);">
23951 <div style="font-size:16px;font-weight:800;margin-bottom:18px;">Post to Confluence</div>
23952 <label style="font-size:12px;font-weight:700;color:var(--muted);">Page Title</label>
23953 <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;">
23954 <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>
23955 <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;">
23956 <div id="confStatus" style="display:none;padding:9px 13px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:14px;"></div>
23957 <div style="display:flex;gap:10px;justify-content:flex-end;">
23958 <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
23959 <button class="button" id="confSubmitBtn" type="button">Post</button>
23960 </div>
23961 </div>
23962 </div>
23963 {% endif %}
23964 <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;">
23965 <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);">
23966 <div style="font-size:28px;font-weight:800;margin-bottom:16px;color:#b23030;">Delete run — irreversible</div>
23967 <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>
23968 <div id="delete-run-status" style="display:none;padding:14px 20px;border-radius:10px;font-size:15px;font-weight:600;margin-bottom:22px;"></div>
23969 <div style="display:flex;gap:18px;justify-content:flex-end;">
23970 <button class="button secondary" id="delete-run-cancel" type="button" style="font-size:15px;padding:12px 28px;">Cancel</button>
23971 <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>
23972 </div>
23973 </div>
23974 </div>
23975 {% if !submodule_rows.is_empty() %}
23976 <div class="submodule-panel">
23977 <div class="toolbar-row">
23978 <div>
23979 <h2 style="margin:0 0 4px;font-size:18px;">Submodule breakdown</h2>
23980 <p class="muted" style="margin:0;">Git submodules detected — each is shown as a separate project slice.</p>
23981 </div>
23982 <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
23983 </div>
23984 <div style="overflow-x:auto;border-radius:10px;border:1px solid var(--line);margin-top:12px;">
23985 <table id="subm-tbl" style="width:100%;border-collapse:collapse;font-size:14px;table-layout:fixed;min-width:1050px;">
23986 <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>
23987 <thead>
23988 <tr>
23989 <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>
23990 <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>
23991 <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>
23992 <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>
23993 <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>
23994 <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>
23995 <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>
23996 <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>
23997 </tr>
23998 </thead>
23999 <tbody>
24000 {% for row in submodule_rows %}
24001 <tr>
24002 <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>
24003 <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>
24004 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.files_analyzed|commas }}</td>
24005 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.total_physical_lines|commas }}</td>
24006 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.code_lines|commas }}</td>
24007 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.comment_lines|commas }}</td>
24008 <td style="padding:10px 6px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap;">{{ row.blank_lines|commas }}</td>
24009 <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>
24010 </tr>
24011 {% endfor %}
24012 </tbody>
24013 </table>
24014 </div>
24015 </div>
24016 {% endif %}
24017
24018 <div class="metrics-tables-stack">
24019
24020 <div class="metrics-table-wrap">
24021 <div class="metrics-table-title">Files</div>
24022 <table class="metrics-table">
24023 <thead>
24024 <tr>
24025 <th>Metric</th>
24026 <th>This Run</th>
24027 <th>Previous</th>
24028 <th>Change</th>
24029 </tr>
24030 </thead>
24031 <tbody>
24032 <tr>
24033 <td>Files analyzed</td>
24034 <td class="mt-val-large">{{ files_analyzed|commas }}</td>
24035 <td>{{ prev_fa_str|commas }}</td>
24036 <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str|commas }}</span></td>
24037 </tr>
24038 <tr>
24039 <td>Files skipped</td>
24040 <td>{{ files_skipped|commas }}</td>
24041 <td>{{ prev_fs_str|commas }}</td>
24042 <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str|commas }}</span></td>
24043 </tr>
24044 <tr>
24045 <td>Files modified</td>
24046 <td class="mt-val-na">—</td>
24047 <td class="mt-val-na">—</td>
24048 <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>
24049 </tr>
24050 <tr>
24051 <td>Files unchanged</td>
24052 <td class="mt-val-na">—</td>
24053 <td class="mt-val-na">—</td>
24054 <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
24055 </tr>
24056 <tr>
24057 <td>Files total</td>
24058 <td class="mt-val-na">—</td>
24059 <td class="mt-val-na">—</td>
24060 <td>{% if let Some(v) = delta_files_total %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
24061 </tr>
24062 </tbody>
24063 </table>
24064 </div>
24065
24066 <div class="metrics-table-wrap">
24067 <div class="metrics-table-title">Line Counts</div>
24068 <table class="metrics-table">
24069 <thead>
24070 <tr>
24071 <th>Metric</th>
24072 <th>This Run</th>
24073 <th>Previous</th>
24074 <th>Change</th>
24075 </tr>
24076 </thead>
24077 <tbody>
24078 <tr>
24079 <td>Physical lines</td>
24080 <td class="mt-val-large">{{ physical_lines|commas }}</td>
24081 <td>{{ prev_pl_str|commas }}</td>
24082 <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str|commas }}</span></td>
24083 </tr>
24084 <tr>
24085 <td>Code lines</td>
24086 <td class="mt-val-large">{{ code_lines|commas }}</td>
24087 <td>{{ prev_cl_str|commas }}</td>
24088 <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str|commas }}</span></td>
24089 </tr>
24090 <tr>
24091 <td>Comment lines</td>
24092 <td>{{ comment_lines|commas }}</td>
24093 <td>{{ prev_cml_str|commas }}</td>
24094 <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str|commas }}</span></td>
24095 </tr>
24096 <tr>
24097 <td>Blank lines</td>
24098 <td>{{ blank_lines|commas }}</td>
24099 <td>{{ prev_bl_str|commas }}</td>
24100 <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str|commas }}</span></td>
24101 </tr>
24102 <tr>
24103 <td>Mixed (separate)</td>
24104 <td>{{ mixed_lines|commas }}</td>
24105 <td class="mt-val-na">—</td>
24106 <td class="mt-val-na">—</td>
24107 </tr>
24108 </tbody>
24109 </table>
24110 </div>
24111
24112 <div class="metrics-tables-lower">
24113 <div class="metrics-table-wrap">
24114 <div class="metrics-table-title">Code Structure</div>
24115 <table class="metrics-table">
24116 <thead>
24117 <tr>
24118 <th>Metric</th>
24119 <th>This Run</th>
24120 </tr>
24121 </thead>
24122 <tbody>
24123 <tr>
24124 <td>Functions</td>
24125 <td>{{ functions|commas }}</td>
24126 </tr>
24127 <tr>
24128 <td>Classes / Types</td>
24129 <td>{{ classes|commas }}</td>
24130 </tr>
24131 <tr>
24132 <td>Variables</td>
24133 <td>{{ variables|commas }}</td>
24134 </tr>
24135 <tr>
24136 <td>Imports</td>
24137 <td>{{ imports|commas }}</td>
24138 </tr>
24139 </tbody>
24140 </table>
24141 </div>
24142
24143 <div class="metrics-table-wrap">
24144 <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
24145 <table class="metrics-table">
24146 <thead>
24147 <tr>
24148 <th>Metric</th>
24149 <th>Change</th>
24150 </tr>
24151 </thead>
24152 <tbody>
24153 <tr>
24154 <td>Lines added</td>
24155 <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>
24156 </tr>
24157 <tr>
24158 <td>Lines removed</td>
24159 <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>
24160 </tr>
24161 <tr>
24162 <td>Lines modified (net)</td>
24163 <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str|commas }}</span></td>
24164 </tr>
24165 <tr>
24166 <td>Lines unmodified</td>
24167 <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
24168 </tr>
24169 </tbody>
24170 </table>
24171 </div>
24172 </div>
24173
24174 </div>
24175
24176 <div class="path-list">
24177 <div class="path-item">
24178 <div class="path-item-label">Project path</div>
24179 {% 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 %}
24180 </div>
24181 <div class="path-item">
24182 <div class="path-item-label">Git branch</div>
24183 {% if let Some(branch) = git_branch %}
24184 <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
24185 {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
24186 {% else %}
24187 <code style="color:var(--muted)">—</code>
24188 {% endif %}
24189 </div>
24190 <div class="path-item">
24191 <div class="path-item-label">Output folder</div>
24192 <code style="display:block;margin-top:4px;overflow-wrap:anywhere;font-size:12px;word-break:break-all;">{{ output_dir }}</code>
24193 </div>
24194 <div class="path-item">
24195 <div class="path-item-label">Run ID</div>
24196 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:4px;">
24197 <code style="font-size:11px;word-break:break-all;">{{ run_id }}</code>
24198 <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
24199 </div>
24200 </div>
24201 </div>
24202 </section>
24203
24204 {% if has_cocomo %}
24205 <div class="cocomo-box" style="margin-top:24px;">
24206 <div class="cocomo-box-head">
24207 <span class="cocomo-box-title">Constructive Cost Model — COCOMO I</span>
24208 <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
24209 <span class="cocomo-mode-pill">{{ cocomo_mode_label }} mode</span>
24210 <span class="cocomo-mode-tip">{{ cocomo_mode_tooltip }}</span>
24211 </span>
24212 </div>
24213 <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
24214 <div class="stat-chip">
24215 <div class="stat-chip-label">Person-months</div>
24216 <div class="stat-chip-val">{{ cocomo_effort_str|commas }}</div>
24217 <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>
24218 </div>
24219 <div class="stat-chip">
24220 <div class="stat-chip-label">Schedule (months)</div>
24221 <div class="stat-chip-val">{{ cocomo_duration_str|commas }}</div>
24222 <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>
24223 </div>
24224 <div class="stat-chip">
24225 <div class="stat-chip-label">Avg. Team Size</div>
24226 <div class="stat-chip-val">{{ cocomo_staff_str|commas }}</div>
24227 <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>
24228 </div>
24229 <div class="stat-chip">
24230 <div class="stat-chip-label">Input KSLOC</div>
24231 <div class="stat-chip-val">{{ cocomo_ksloc_str|commas }}K</div>
24232 <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>
24233 </div>
24234 </div>
24235 <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>
24236 </div>
24237 {% endif %}
24238
24239 <!-- ── Tests & Coverage brief summary ────────────────────────────────── -->
24240 <div class="cocomo-box" style="margin-top:24px;">
24241 <div class="cocomo-box-head">
24242 <span class="cocomo-box-title">Tests & Coverage</span>
24243 {% if has_coverage_data %}
24244 <span class="cocomo-mode-pill-wrap" style="margin-left:10px;">
24245 <span class="cocomo-mode-pill" style="background:rgba(34,197,94,0.14);color:#16a34a;">Coverage data present</span>
24246 </span>
24247 {% endif %}
24248 </div>
24249 <div class="summary-strip" style="margin-top:0;grid-template-columns:repeat(4,1fr);">
24250 <div class="stat-chip">
24251 <div class="stat-chip-val" data-fmt="{{ test_count }}">{{ test_count|commas }}</div>
24252 <div class="stat-chip-label">Test Functions</div>
24253 <div class="stat-chip-tip">Lexically detected test case / function definitions</div>
24254 </div>
24255 <div class="stat-chip">
24256 {% if has_coverage_data %}
24257 <div class="stat-chip-val" style="color:#16a34a;">{{ cov_line_pct }}%</div>
24258 {% else %}
24259 <div class="stat-chip-val" style="color:var(--muted);">—</div>
24260 {% endif %}
24261 <div class="stat-chip-label">Line Coverage</div>
24262 <div class="stat-chip-tip">Overall line coverage from LCOV / Cobertura / JaCoCo data</div>
24263 </div>
24264 <div class="stat-chip">
24265 {% if !cov_fn_pct.is_empty() %}
24266 <div class="stat-chip-val" style="color:#16a34a;">{{ cov_fn_pct }}%</div>
24267 {% else %}
24268 <div class="stat-chip-val" style="color:var(--muted);">—</div>
24269 {% endif %}
24270 <div class="stat-chip-label">Fn Coverage</div>
24271 <div class="stat-chip-tip">Overall function coverage — requires function-level LCOV data</div>
24272 </div>
24273 <div class="stat-chip">
24274 {% if !cov_branch_pct.is_empty() %}
24275 <div class="stat-chip-val" style="color:#16a34a;">{{ cov_branch_pct }}%</div>
24276 {% else %}
24277 <div class="stat-chip-val" style="color:var(--muted);">—</div>
24278 {% endif %}
24279 <div class="stat-chip-label">Branch Coverage</div>
24280 <div class="stat-chip-tip">Overall branch coverage — requires branch-level LCOV data</div>
24281 </div>
24282 </div>
24283 {% if has_coverage_data %}
24284 <div class="cocomo-box-note">Lines instrumented: <strong>{{ cov_lines_summary }}</strong> · Open the full HTML report for a per-file breakdown.</div>
24285 {% else %}
24286 <div class="cocomo-box-note">No code coverage detected. Re-run with <code>--lcov-path <coverage.info></code> to populate this section.</div>
24287 {% endif %}
24288 </div>
24289
24290 <div class="section-pair">
24291 <section class="panel">
24292 <div class="toolbar-row">
24293 <div>
24294 <h2>Language Breakdown</h2>
24295 <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
24296 </div>
24297 <button class="r-expand-btn" id="result-lang-overview-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
24298 </div>
24299 <div id="result-lang-charts" style="margin:0 0 8px;"></div>
24300 </section>
24301
24302 <section class="panel r-chart-section">
24303 <div class="toolbar-row" style="margin-bottom:16px;">
24304 <div>
24305 <h2>Visualizations</h2>
24306 <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
24307 </div>
24308 </div>
24309
24310 <div class="r-viz-grid">
24311 <div class="r-viz-card">
24312 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
24313 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Language Composition</p>
24314 <button class="r-expand-btn" id="r-composition-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
24315 </div>
24316 <div class="r-chart-tab-bar">
24317 <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
24318 <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
24319 </div>
24320 <div class="r-chart-container" id="r-composition-chart"></div>
24321 </div>
24322 <div class="r-viz-card">
24323 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
24324 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Files vs Code Lines</p>
24325 <button class="r-expand-btn" id="r-scatter-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
24326 </div>
24327 <div class="r-chart-container" id="r-scatter-chart"></div>
24328 </div>
24329 {% if has_semantic_data %}
24330 <div class="r-viz-card">
24331 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px;">
24332 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Semantic Metrics</p>
24333 <select class="r-chart-select" id="r-semantic-metric">
24334 <option value="functions">Functions</option>
24335 <option value="classes">Classes</option>
24336 <option value="variables">Variables</option>
24337 <option value="imports">Imports</option>
24338 <option value="tests">Tests</option>
24339 </select>
24340 <button class="r-expand-btn" id="r-semantic-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
24341 </div>
24342 <div class="r-chart-container" id="r-semantic-chart"></div>
24343 </div>
24344 {% endif %}
24345 <div class="r-viz-card">
24346 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
24347 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Comment Density</p>
24348 <button class="r-expand-btn" id="r-density-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
24349 </div>
24350 <div class="r-chart-container" id="r-density-chart"></div>
24351 </div>
24352 <div class="r-viz-card">
24353 <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
24354 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Avg Lines per File</p>
24355 <button class="r-expand-btn" id="r-avglines-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
24356 </div>
24357 <div class="r-chart-container" id="r-avglines-chart"></div>
24358 </div>
24359 <div class="r-viz-card">
24360 <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;">
24361 <p class="r-viz-card-title" style="margin:0;flex:1 1 auto;">Repository Overview</p>
24362 <select class="r-chart-select" id="r-sub-metric">
24363 <option value="code">Code Lines</option>
24364 <option value="comment">Comments</option>
24365 <option value="blank">Blank Lines</option>
24366 <option value="physical">Physical Lines</option>
24367 <option value="files">Files</option>
24368 </select>
24369 <select class="r-chart-select" id="r-sub-sort">
24370 <option value="desc">Value ↓</option>
24371 <option value="asc">Value ↑</option>
24372 <option value="name">Name A→Z</option>
24373 </select>
24374 <button class="r-expand-btn" id="r-submodule-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
24375 </div>
24376 <div class="r-chart-container" id="r-submodule-chart"></div>
24377 </div>
24378 </div>
24379
24380 </section>
24381 </div>
24382
24383 </div>
24384
24385 <div id="r-tt" aria-hidden="true"></div>
24386
24387 <script nonce="{{ csp_nonce }}">
24388 (function () {
24389 var body = document.body;
24390 var themeToggle = document.getElementById('theme-toggle');
24391 var storageKey = 'oxide-sloc-theme';
24392
24393 function applyTheme(theme) {
24394 body.classList.toggle('dark-theme', theme === 'dark');
24395 }
24396
24397 function loadSavedTheme() {
24398 try {
24399 var saved = localStorage.getItem(storageKey);
24400 if (saved === 'dark' || saved === 'light') {
24401 applyTheme(saved);
24402 }
24403 } catch (e) {}
24404 }
24405
24406 if (themeToggle) {
24407 themeToggle.addEventListener('click', function () {
24408 var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
24409 applyTheme(nextTheme);
24410 try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
24411 });
24412 }
24413
24414 Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
24415 button.addEventListener('click', function () {
24416 var value = button.getAttribute('data-copy-value') || '';
24417 if (!value) return;
24418 var originalText = button.textContent;
24419 function flashSuccess() {
24420 button.textContent = 'Copied!';
24421 setTimeout(function () { button.textContent = originalText; }, 1800);
24422 }
24423 function flashFail() {
24424 button.textContent = 'Copy failed';
24425 setTimeout(function () { button.textContent = originalText; }, 2000);
24426 }
24427 if (navigator.clipboard && navigator.clipboard.writeText) {
24428 navigator.clipboard.writeText(value).then(flashSuccess, function () {
24429 fallbackCopy(value, flashSuccess, flashFail);
24430 });
24431 } else {
24432 fallbackCopy(value, flashSuccess, flashFail);
24433 }
24434 });
24435 });
24436 function fallbackCopy(text, onSuccess, onFail) {
24437 try {
24438 var ta = document.createElement('textarea');
24439 ta.value = text;
24440 ta.style.position = 'fixed';
24441 ta.style.top = '-9999px';
24442 ta.style.left = '-9999px';
24443 document.body.appendChild(ta);
24444 ta.focus();
24445 ta.select();
24446 var ok = document.execCommand('copy');
24447 document.body.removeChild(ta);
24448 if (ok) { onSuccess(); } else { onFail(); }
24449 } catch (e) { onFail(); }
24450 }
24451
24452 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
24453 btn.addEventListener('click', function () {
24454 var folder = btn.getAttribute('data-folder') || '';
24455 if (!folder) return;
24456 var orig = btn.textContent;
24457 fetch('/open-path?path=' + encodeURIComponent(folder))
24458 .then(function (r) { return r.json(); })
24459 .then(function (d) {
24460 if (d && d.server_mode_disabled) {
24461 window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
24462 } else if (d && d.ok) {
24463 btn.textContent = 'Opened!';
24464 setTimeout(function () { btn.textContent = orig; }, 1800);
24465 }
24466 })
24467 .catch(function () {
24468 btn.textContent = 'Failed';
24469 setTimeout(function () { btn.textContent = orig; }, 2000);
24470 });
24471 });
24472 });
24473
24474 loadSavedTheme();
24475
24476 // ── Compact number formatting for stat chips ──────────────────────────
24477 (function(){
24478 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();}
24479 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
24480 var raw=parseInt(chip.getAttribute('data-raw'),10);
24481 if(isNaN(raw))return;
24482 var valEl=chip.querySelector('.stat-chip-val');
24483 if(valEl)valEl.textContent=fmt(raw);
24484 var exactEl=chip.querySelector('.stat-chip-exact');
24485 if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
24486 });
24487 // Code density chip
24488 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-density]')).forEach(function(chip){
24489 var code=parseInt(chip.getAttribute('data-code'),10);
24490 var phys=parseInt(chip.getAttribute('data-physical'),10);
24491 if(isNaN(code)||isNaN(phys)||phys===0)return;
24492 var pct=(code/phys*100).toFixed(1)+'%';
24493 var valEl=chip.querySelector('.stat-chip-val');
24494 if(valEl)valEl.textContent=pct;
24495 });
24496 // Populate author handle from data-author attribute
24497 Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-author]')).forEach(function(chip){
24498 var author=chip.getAttribute('data-author');
24499 var el=chip.querySelector('.author-handle');
24500 if(el)el.textContent='/'+author.replace(/\s+/g,'');
24501 });
24502 // Click-to-copy on run-id-chip elements
24503 Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-copy]')).forEach(function(chip){
24504 chip.addEventListener('click',function(){
24505 var val=chip.getAttribute('data-copy');
24506 if(!val)return;
24507 if(navigator.clipboard){navigator.clipboard.writeText(val).catch(function(){});}
24508 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);}
24509 chip.classList.add('chip-copied-flash');
24510 setTimeout(function(){chip.classList.remove('chip-copied-flash');},900);
24511 });
24512 });
24513 // Format delta card values with data-raw using comma-separated full numbers
24514 Array.prototype.slice.call(document.querySelectorAll('.delta-cards-inline .delta-card-inline[data-raw]')).forEach(function(card){
24515 var raw=parseInt(card.getAttribute('data-raw'),10);
24516 if(isNaN(raw))return;
24517 var valEl=card.querySelector('.delta-card-val');
24518 if(valEl)valEl.textContent=raw.toLocaleString();
24519 });
24520 // Format code-before / code-now numbers in the compare banner stats line
24521 Array.prototype.slice.call(document.querySelectorAll('.compare-banner-stats [data-raw]')).forEach(function(el){
24522 var raw=parseInt(el.getAttribute('data-raw'),10);
24523 if(!isNaN(raw))el.textContent=raw.toLocaleString();
24524 });
24525 })();
24526
24527 // ── Shared tooltip for all result-page charts ─────────────────────────
24528 var rTT=(function(){
24529 var el=document.getElementById('r-tt');
24530 if(!el)return{s:function(){},h:function(){},m:function(){}};
24531 function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
24532 function hide(){el.style.display='none';}
24533 function move(e){
24534 var x=e.clientX+16,y=e.clientY-12;
24535 var r=el.getBoundingClientRect();
24536 if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
24537 if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
24538 el.style.left=x+'px';el.style.top=y+'px';
24539 }
24540 return{s:show,h:hide,m:move};
24541 })();
24542 window.rTT=rTT;
24543
24544 // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
24545 (function(){
24546 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
24547 document.addEventListener('mouseover',function(e){
24548 var t=e.target;
24549 while(t&&t.getAttribute){
24550 var l=t.getAttribute('data-ttl');
24551 if(l!==null){
24552 var v=t.getAttribute('data-ttv')||'';
24553 rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v).replace(/\n/g,'<br>'));
24554 return;
24555 }
24556 t=t.parentNode;
24557 }
24558 });
24559 document.addEventListener('mouseout',function(e){
24560 var t=e.target;
24561 while(t&&t.getAttribute){
24562 if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
24563 t=t.parentNode;
24564 }
24565 });
24566 document.addEventListener('mousemove',function(e){
24567 var el=document.getElementById('r-tt');
24568 if(el&&el.style.display!=='none')rTT.m(e);
24569 });
24570 window.addEventListener('blur',function(){rTT.h();});
24571 document.addEventListener('visibilitychange',function(){if(document.hidden)rTT.h();});
24572 })();
24573
24574 // ── Language overview charts ───────────────────────────────────────────
24575 (function(){
24576 var D={{ lang_chart_json|safe }};
24577 if(!D||!D.length)return;
24578 var el=document.getElementById('result-lang-charts');
24579 if(!el)return;
24580 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
24581 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
24582 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
24583 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();}
24584 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
24585 function px(n){return Math.round(n);}
24586 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+'"';}
24587 // Largest font size (<=10) at which `t` fits in a `w`-wide segment, or 0 if
24588 // it cannot fit legibly even at the 6.5 floor. Lets bar labels shrink to fit
24589 // instead of vanishing; the SVG scales up in Full View so small fonts stay legible.
24590 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;}
24591 var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
24592
24593 // Donut chart — height matches the stacked-bar chart so both panels align
24594 var rHb_d=28;
24595 var DH=Math.max(220,D.length*rHb_d+32);
24596 var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48;
24597 var legX=208,DW=395;
24598 var legCount=D.length;
24599 var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
24600 var legYStart=Math.round((DH-legCount*legSpacing)/2);
24601 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">';
24602 // One shared transition on every donut element so slices, leader lines,
24603 // outside labels, % labels and the legend all animate together as a single
24604 // picture when a language is hovered. Slices scale from the donut centre.
24605 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>';
24606 if(D.length===1){
24607 var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
24608 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+'"/>';
24609 } else {
24610 var smalls=[];
24611 var ang=-Math.PI/2;
24612 D.forEach(function(d,i){
24613 var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
24614 var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
24615 var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
24616 var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
24617 var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
24618 var pct=Math.round(d.code/tot*100);
24619 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"/>';
24620 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]});}
24621 ang+=sw;
24622 });
24623 // Small slices (<5%) get outside labels positioned near each slice's own
24624 // angular position (a slice on the left gets its label/leader on the left),
24625 // then nudged apart horizontally so text never overlaps. Leader lines point
24626 // from each slice to its label. Horizontal text keeps long names legible;
24627 // the whole SVG scales up in Full View so these stay readable there too.
24628 if(smalls.length){
24629 smalls.sort(function(a,b){return a.mAng-b.mAng;});
24630 var sPad=6,sRowY=11;
24631 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)));});
24632 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;}
24633 var sLast=smalls[smalls.length-1],sOver=sLast.x+sLast.w/2-(DW-sPad);
24634 if(sOver>0)smalls.forEach(function(sm){sm.x-=sOver;});
24635 smalls.forEach(function(sm){
24636 var axx=cx+Ro*Math.cos(sm.mAng),ayy=cy+Ro*Math.sin(sm.mAng);
24637 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;"/>';
24638 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>';
24639 });
24640 }
24641 }
24642 ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
24643 ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
24644 D.forEach(function(d,i){
24645 var ly=legYStart+i*legSpacing;
24646 var pctL=Math.round(d.code/tot*100);
24647 var ttL=String(d.lang).replace(/&/g,'&').replace(/"/g,'"');
24648 var ttV=(fmt(d.code)+' code lines ('+pctL+'%)').replace(/&/g,'&').replace(/"/g,'"');
24649 ds+='<g data-lang="'+esc(d.lang)+'" data-ttl="'+ttL+'" data-ttv="'+ttV+'" style="cursor:pointer;">';
24650 ds+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+(legSpacing||14)+'" fill="transparent"/>';
24651 ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
24652 ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
24653 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>';
24654 ds+='</g>';
24655 });
24656 ds+='</svg>';
24657
24658 // Horizontal stacked-bar chart — fills container width
24659 var maxT=Math.max.apply(null,D.map(function(d){return d.physical||d.code+d.comments+d.blanks;}))||1;
24660 var LW=108,BW=260,svgW=LW+BW+68;
24661 var barRhb=Math.min(48,Math.max(28,Math.floor((DH-32)/D.length)));
24662 var barBH=Math.min(32,Math.round(barRhb*0.7));
24663 var SH=DH;
24664 var barTopPad=Math.max(6,Math.round((SH-D.length*barRhb-18)/2));
24665 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">';
24666 D.forEach(function(d,i){
24667 var y=barTopPad+i*barRhb,x=LW;
24668 var phys=d.physical||d.code+d.comments+d.blanks;
24669 var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
24670 var lmid=y+barBH/2+4;
24671 // Combined breakdown shown when hovering the row, the language name, or the
24672 // total at the bar end (\n becomes a line break in the tooltip).
24673 var ttv='Code: '+fmt(d.code)+'\nComments: '+fmt(d.comments)+'\nBlank: '+fmt(d.blanks)+'\nTotal: '+fmt(phys);
24674 bs+='<g class="lang-bar-row">';
24675 // Hit area ends just past the total label so empty space to the right of the
24676 // bar does not trigger the tooltip — only the name, bar and total are hot.
24677 var hitW=px(LW+phys/maxT*BW+8+(String(fmt(phys)).length*6.8)+6);
24678 bs+='<rect'+tt(d.lang,ttv)+' x="0" y="'+y+'" width="'+hitW+'" height="'+barBH+'" fill="transparent" style="cursor:pointer;"/>';
24679 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>';
24680 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;}
24681 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;}
24682 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>';}
24683 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>';
24684 bs+='</g>';
24685 });
24686 var ly=SH-14;
24687 var totC=D.reduce(function(a,d){return a+(d.code||0);},0);
24688 var totCm=D.reduce(function(a,d){return a+(d.comments||0);},0);
24689 var totBl=D.reduce(function(a,d){return a+(d.blanks||0);},0);
24690 var totAll=totC+totCm+totBl||1;
24691 function legTT(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'"')+'"';}
24692 var ttC=legTT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
24693 var ttCm=legTT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
24694 var ttBl=legTT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
24695 var legSt=LW+Math.max(0,Math.round((BW-194)/2));
24696 bs+='<g data-kind="code" style="cursor:pointer;">'
24697 +'<rect x="'+legSt+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC+'/>'
24698 +'<rect x="'+legSt+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC+'/>'
24699 +'<text x="'+(legSt+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>'
24700 +'</g>';
24701 bs+='<g data-kind="comment" style="cursor:pointer;">'
24702 +'<rect x="'+(legSt+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm+'/>'
24703 +'<rect x="'+(legSt+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm+'/>'
24704 +'<text x="'+(legSt+71)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>'
24705 +'</g>';
24706 bs+='<g data-kind="blank" style="cursor:pointer;">'
24707 +'<rect x="'+(legSt+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/>'
24708 +'<rect x="'+(legSt+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl+'/>'
24709 +'<text x="'+(legSt+158)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>'
24710 +'</g>';
24711 bs+='</svg>';
24712 el.innerHTML='<div class="r-lang-overview">'+
24713 '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
24714 '<div class="r-lang-overview-cell" style="flex:2 1 340px;"><p>Line Mix per Language</p>'+bs+'</div>'+
24715 '</div>';
24716 function wireDonutLegend(svg){
24717 if(!svg)return;
24718 // Every donut element carries data-lang: slices (path/circle), leader lines,
24719 // outside labels + % labels (text) and legend rows (g). Hovering any one of
24720 // them emphasises that language across all of them and fades the rest, so the
24721 // slice, its leader line, its label and its legend row move as one picture.
24722 var items=svg.querySelectorAll('[data-lang]');
24723 function emph(el,st){ // st: 1 = highlight, -1 = fade, 0 = reset
24724 var tag=el.tagName.toLowerCase();
24725 if(tag==='path'||tag==='circle'){
24726 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)';}
24727 else if(st===-1){el.style.opacity='0.24';el.style.filter='none';el.style.transform='none';}
24728 else{el.style.opacity='';el.style.filter='';el.style.transform='';}
24729 }else if(tag==='line'){
24730 if(st===1){el.style.opacity='1';el.style.strokeWidth='1.8';}
24731 else if(st===-1){el.style.opacity='0.1';el.style.strokeWidth='';}
24732 else{el.style.opacity='';el.style.strokeWidth='';}
24733 }else if(tag==='text'){
24734 if(st===1){el.style.opacity='1';el.style.fontWeight='800';}
24735 else if(st===-1){el.style.opacity='0.18';el.style.fontWeight='';}
24736 else{el.style.opacity='';el.style.fontWeight='';}
24737 }else{ // legend group
24738 if(st===1){el.style.opacity='1';}
24739 else if(st===-1){el.style.opacity='0.4';}
24740 else{el.style.opacity='';}
24741 }
24742 }
24743 function hl(lang){for(var i=0;i<items.length;i++){emph(items[i],items[i].getAttribute('data-lang')===lang?1:-1);}}
24744 function rst(){for(var i=0;i<items.length;i++){emph(items[i],0);}}
24745 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();});
24746 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();});
24747 svg.addEventListener('mouseout',function(e){if(e.relatedTarget&&svg.contains(e.relatedTarget))return;rst();});
24748 }
24749 function wireMixLegend(svg){
24750 if(!svg)return;
24751 var legGs=svg.querySelectorAll('g[data-kind]');
24752 var allRects=svg.querySelectorAll('rect[data-kind]');
24753 if(!legGs.length)return;
24754 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';}}
24755 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='';}}
24756 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]);}
24757 }
24758 wireDonutLegend(el.querySelector('svg'));
24759 wireMixLegend(el.querySelectorAll('svg')[1]);
24760
24761 // ── Language breakdown Full View expand ─────────────────────────────────
24762 var langOvBtn=document.getElementById('result-lang-overview-expand');
24763 if(langOvBtn){langOvBtn.addEventListener('click',function(){
24764 var src=document.getElementById('result-lang-charts');
24765 if(!src)return;
24766 var overlay=document.createElement('div');
24767 overlay.className='r-chart-modal-overlay';
24768 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>';
24769 document.body.appendChild(overlay);
24770 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
24771 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
24772 var wrap=document.getElementById('result-lang-overview-modal-wrap');
24773 if(wrap){
24774 wrap.innerHTML=src.innerHTML;
24775 var svgs=wrap.querySelectorAll('svg');
24776 for(var i=0;i<svgs.length;i++){
24777 svgs[i].removeAttribute('width');
24778 svgs[i].removeAttribute('height');
24779 svgs[i].style.cssText='display:block;width:100%;height:auto;';
24780 }
24781 var ov=wrap.querySelector('.r-lang-overview');
24782 if(ov){ov.style.flexWrap='nowrap';ov.style.alignItems='stretch';}
24783 var cells=wrap.querySelectorAll('.r-lang-overview-cell');
24784 if(cells.length>0)cells[0].style.cssText='flex:1 1 0;max-width:none;justify-content:center;';
24785 if(cells.length>1)cells[1].style.cssText='flex:1 1 0;max-width:none;';
24786 wireDonutLegend(wrap.querySelector('svg'));
24787 wireMixLegend(wrap.querySelectorAll('svg')[1]);
24788 requestAnimationFrame(function(){
24789 var ss=wrap.querySelectorAll('svg');
24790 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%;';}}
24791 });
24792 }
24793 });}
24794 })();
24795
24796 // ── Extended charts (composition, scatter, semantic, submodule) ─────────
24797 (function(){
24798 var LANG_D={{ lang_chart_json|safe }};
24799 var SCAT_D={{ scatter_chart_json|safe }};
24800 var SEM_D={{ semantic_chart_json|safe }};
24801 var SUB_D={{ submodule_chart_json|safe }};
24802 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
24803 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
24804 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();}
24805 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
24806 function px(n){return Math.round(n);}
24807 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+'"';}
24808 // Largest font size (<=10) at which `t` fits in a `w`-wide bar segment, or 0
24809 // when it cannot fit legibly even at the 6.5 floor (labels shrink to fit
24810 // rather than disappear; the SVG scales up in Full View).
24811 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;}
24812
24813 // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
24814 function renderCompositionInEl(el,mode,shOvr){
24815 if(!el||!LANG_D||!LANG_D.length)return;
24816 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
24817 var LW=110,SH=shOvr||300;
24818 var svgW=Math.max(320,el.offsetWidth||480);
24819 var BW=Math.max(120,svgW-LW-80);
24820 var legendH=24,topPad=4;
24821 var n=LANG_D.length||1;
24822 var rowTotal=Math.floor((SH-legendH-topPad)/n);
24823 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
24824 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">';
24825 var totC2=LANG_D.reduce(function(a,d){return a+(d.code||0);},0);
24826 var totCm2=LANG_D.reduce(function(a,d){return a+(d.comments||0);},0);
24827 var totBl2=LANG_D.reduce(function(a,d){return a+(d.blanks||0);},0);
24828 var totAll2=totC2+totCm2+totBl2||1;
24829 if(mode==='pct'){
24830 LANG_D.forEach(function(d,i){
24831 var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
24832 var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
24833 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
24834 var lmid=y+Math.floor(bH/2)+4;
24835 var ttvc='Code: '+fmt(d.code||0)+'\nComments: '+fmt(d.comments||0)+'\nBlank: '+fmt(d.blanks||0)+'\nTotal: '+fmt(d.physical||tot2);
24836 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>';
24837 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;}
24838 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;}
24839 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>';}
24840 var pct=Math.round((d.code||0)/tot2*100);
24841 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>';
24842 });
24843 } else {
24844 var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
24845 LANG_D.forEach(function(d,i){
24846 var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
24847 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
24848 var lmid=y+Math.floor(bH/2)+4;
24849 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));
24850 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>';
24851 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;}
24852 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;}
24853 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>';}
24854 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>';
24855 });
24856 }
24857 var ly=SH-legendH+4;
24858 var legSt2=LW+Math.max(0,Math.round((BW-194)/2));
24859 function legTT2(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'"')+'"';}
24860 var ttC2=legTT2('Code lines',fmt(totC2)+' total ('+Math.round(totC2/totAll2*100)+'%)');
24861 var ttCm2=legTT2('Comment lines',fmt(totCm2)+' total ('+Math.round(totCm2/totAll2*100)+'%)');
24862 var ttBl2=legTT2('Blank lines',fmt(totBl2)+' total ('+Math.round(totBl2/totAll2*100)+'%)');
24863 s+='<g data-kind="code" style="cursor:pointer;">'
24864 +'<rect x="'+legSt2+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC2+'/>'
24865 +'<rect x="'+legSt2+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC2+'/>'
24866 +'<text x="'+(legSt2+13)+'" y="'+(ly+9)+'"'+ttC2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Code</text>'
24867 +'</g>';
24868 s+='<g data-kind="comment" style="cursor:pointer;">'
24869 +'<rect x="'+(legSt2+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm2+'/>'
24870 +'<rect x="'+(legSt2+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm2+'/>'
24871 +'<text x="'+(legSt2+71)+'" y="'+(ly+9)+'"'+ttCm2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Comments</text>'
24872 +'</g>';
24873 s+='<g data-kind="blank" style="cursor:pointer;">'
24874 +'<rect x="'+(legSt2+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl2+'/>'
24875 +'<rect x="'+(legSt2+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl2+'/>'
24876 +'<text x="'+(legSt2+158)+'" y="'+(ly+9)+'"'+ttBl2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Blanks</text>'
24877 +'</g>';
24878 s+='</svg>';
24879 el.innerHTML=s;
24880 wireMixLegendEl(el);
24881 }
24882 function wireMixLegendEl(container){
24883 var svg=container&&container.querySelector('svg');
24884 if(!svg)return;
24885 var legGs=svg.querySelectorAll('g[data-kind]');
24886 var allRects=svg.querySelectorAll('rect[data-kind]');
24887 if(!legGs.length)return;
24888 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';}}
24889 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='';}}
24890 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]);}
24891 }
24892 function renderComposition(mode){renderCompositionInEl(document.getElementById('r-composition-chart'),mode,0);}
24893 renderComposition('abs');
24894 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
24895 btn.addEventListener('click',function(){
24896 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
24897 btn.classList.add('active');
24898 renderComposition(btn.getAttribute('data-rcomp'));
24899 });
24900 });
24901
24902 // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
24903 function wireScatterLegend(container){
24904 var svg=container&&container.querySelector('svg');
24905 if(!svg)return;
24906 var legGs=svg.querySelectorAll('g[data-lang]');
24907 var circs=svg.querySelectorAll('circle[data-lang]');
24908 var labs=svg.querySelectorAll('text[data-lang]');
24909 if(!legGs.length)return;
24910 // Raise an element to the top of its parent so the hovered bubble and its
24911 // name/number labels sit above overlapping neighbours (clustered bubbles
24912 // otherwise bury the one you are trying to read).
24913 function raise(el){if(el&&el.parentNode)el.parentNode.appendChild(el);}
24914 function hl(lang){
24915 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';}}
24916 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';}}
24917 for(var j=0;j<legGs.length;j++){legGs[j].style.opacity=legGs[j].getAttribute('data-lang')===lang?'1':'0.38';}}
24918 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='';}}
24919 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]);}
24920 }
24921 function renderScatterInEl(el,hOvr){
24922 if(!el||!SCAT_D||!SCAT_D.length)return;
24923 var n=SCAT_D.length;
24924 var H=hOvr||300,PL=52,PB=36,PT=44;
24925 var W=Math.max(320,el.offsetWidth||480);
24926 var cH=H-PT-PB;
24927 // Legend: max 2 columns, fills vertical space. The compact card shows the
24928 // top languages by code lines plus a "+N more" row linking to Full View;
24929 // Full View (hOvr set) shows every language across up to 2 tall columns.
24930 var compact=!hOvr;
24931 var availH=Math.max(120,H-24);
24932 var rowsFit=Math.max(2,Math.floor(availH/18));
24933 var legTrunc=compact&&(n>2*rowsFit);
24934 var legShown=legTrunc?(2*rowsFit-1):n;
24935 var legTotal=legTrunc?(2*rowsFit):n;
24936 var legCols=legTotal>Math.min(rowsFit,18)?2:1;
24937 var legPerCol=Math.ceil(legTotal/legCols);
24938 var legRowH=Math.max(14,Math.min(30,Math.floor(availH/legPerCol)));
24939 var legColW=hOvr?144:130;
24940 var LG=26;
24941 var legW=legCols*legColW;
24942 var cW=W-PL-LG-legW;
24943 var legOrder=SCAT_D.map(function(_,i){return i;}).sort(function(a,b){return (SCAT_D[b].code||0)-(SCAT_D[a].code||0);});
24944 var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
24945 var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
24946 var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
24947 // log1p scale on X to prevent outlier files-count from collapsing all others to the left
24948 var logMaxF=Math.log1p(maxF);
24949 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">';
24950 // Smooth the legend-hover fade so bubbles + labels animate together.
24951 s+='<style>.scat-svg circle,.scat-svg text,.scat-svg g{transition:opacity .2s ease,filter .2s ease;}</style>';
24952 // Y grid lines (linear)
24953 [0,0.25,0.5,0.75,1].forEach(function(t){
24954 var y=PT+cH*(1-t);
24955 s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
24956 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>';
24957 });
24958 // X grid lines (log1p scale — tick labels show actual file counts at those positions)
24959 [0,0.25,0.5,0.75,1].forEach(function(t){
24960 var x=PL+cW*t;
24961 var xVal=t>0?Math.round(Math.expm1(t*logMaxF)):0;
24962 s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
24963 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>';
24964 });
24965 // Full View (hOvr set) has the vertical room to show the per-bubble value
24966 // line; the compact card shows only the language label to avoid the
24967 // overlapping-label clutter seen when bubbles cluster together.
24968 var showVal=!!hOvr;
24969 SCAT_D.forEach(function(d,i){
24970 // X uses log1p so outlier languages (many files) don't push others to the far left
24971 var cx2=PL+(logMaxF>0?Math.log1p(Math.max(1,d.files))/logMaxF:0.5)*cW;
24972 var cy2=PT+cH-d.code/maxC*cH;
24973 var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
24974 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"/>';
24975 // Label(s) centred directly above bubble; clamp to stay inside the plot top.
24976 if(showVal){
24977 var ty2=Math.max(24,px(cy2)-px(r)-3);
24978 var ty1=Math.max(12,ty2-14);
24979 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>';
24980 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>';
24981 }else{
24982 var ly2=Math.max(12,px(cy2)-px(r)-3);
24983 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>';
24984 }
24985 });
24986 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>';
24987 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>';
24988 // Legend (right side — top languages, max 2 columns, fills height)
24989 var legX=PL+cW+LG;
24990 var legBlockH=legPerCol*legRowH;
24991 var legY0=Math.max(8,Math.floor((H-legBlockH)/2));
24992 function legXY(k){return {x:legX+Math.floor(k/legPerCol)*legColW,y:legY0+(k%legPerCol)*legRowH};}
24993 for(var lk=0;lk<legShown;lk++){
24994 var oi=legOrder[lk],ld=SCAT_D[oi],lcol=COLS[oi%COLS.length];
24995 var lp=legXY(lk),ly=lp.y+Math.floor(legRowH/2);
24996 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;">';
24997 s+='<rect x="'+lp.x+'" y="'+lp.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
24998 s+='<rect x="'+lp.x+'" y="'+(ly-6)+'" width="22" height="12" rx="2" fill="'+lcol+'" opacity="0.88" style="pointer-events:none;"/>';
24999 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>';
25000 s+='</g>';
25001 }
25002 if(legTrunc){
25003 var pm=legXY(legShown),lym=pm.y+Math.floor(legRowH/2);
25004 s+='<g data-more="1" style="cursor:pointer;">';
25005 s+='<rect x="'+pm.x+'" y="'+pm.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
25006 s+='<rect x="'+pm.x+'" y="'+(lym-6)+'" width="22" height="12" rx="2" fill="#9a8c82" opacity="0.45" style="pointer-events:none;"/>';
25007 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>';
25008 s+='</g>';
25009 }
25010 s+='</svg>';
25011 el.innerHTML=s;
25012 wireScatterLegend(el);
25013 var moreEl=el.querySelector('g[data-more]');
25014 if(moreEl)moreEl.addEventListener('click',function(){var b=document.getElementById('r-scatter-expand');if(b)b.click();});
25015 }
25016 renderScatterInEl(document.getElementById('r-scatter-chart'),0);
25017
25018 // ── Semantic: horizontal bar chart (one bar per language) ─────────────
25019 // Horizontal layout avoids the portrait-aspect scaling bug that plagued
25020 // the old vertical column layout on wide containers.
25021 function renderSemanticInEl(el,key,sh){
25022 if(!el||!SEM_D||!SEM_D.length)return;
25023 var n2=SEM_D.length||1;
25024 var LW=112,SH=sh||Math.max(180,n2*28+26);
25025 var svgW=Math.max(320,el.offsetWidth||480);
25026 var BW=Math.max(120,svgW-LW-80);
25027 var topPad=4,botPad=14;
25028 var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
25029 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
25030 var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
25031 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">';
25032 SEM_D.forEach(function(d,i){
25033 var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
25034 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>';
25035 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"/>';
25036 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>';
25037 });
25038 s+='</svg>';
25039 el.innerHTML=s;
25040 }
25041 function renderSemantic(key){renderSemanticInEl(document.getElementById('r-semantic-chart'),key,0);}
25042 var semSel=document.getElementById('r-semantic-metric');
25043 if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);syncRowHeights();});}
25044 var semExpand=document.getElementById('r-semantic-expand');
25045 if(semExpand){
25046 semExpand.addEventListener('click',function(){
25047 var key=semSel?semSel.value:'functions';
25048 var n=SEM_D.length||1;
25049 var maxH=Math.max(360,Math.floor(window.innerHeight*0.82)-130);
25050 var modalH=Math.min(Math.max(360,n*38+60),maxH);
25051 var overlay=document.createElement('div');
25052 overlay.className='r-chart-modal-overlay';
25053 var optHtml=
25054 '<option value="functions"'+(key==='functions'?' selected':'')+'>Functions</option>'
25055 +'<option value="classes"'+(key==='classes'?' selected':'')+'>Classes</option>'
25056 +'<option value="variables"'+(key==='variables'?' selected':'')+'>Variables</option>'
25057 +'<option value="imports"'+(key==='imports'?' selected':'')+'>Imports</option>'
25058 +'<option value="tests"'+(key==='tests'?' selected':'')+'>Tests</option>';
25059 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>';
25060 document.body.appendChild(overlay);
25061 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
25062 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
25063 var modalEl=document.getElementById('r-sem-modal-chart');
25064 if(modalEl){setTimeout(function(){renderSemanticInEl(modalEl,key,modalH);},30);}
25065 var modalSel=document.getElementById('r-sem-modal-metric');
25066 if(modalSel){modalSel.addEventListener('change',function(){renderSemanticInEl(modalEl,modalSel.value,modalH);});}
25067 });
25068 }
25069
25070 // ── Expand buttons: re-render charts at large size inside modal ──────────
25071 (function(){
25072 function makeExpandModal(title,mH,subtitle,ctrlHtml){
25073 var overlay=document.createElement('div');
25074 overlay.className='r-chart-modal-overlay';
25075 var subHtml=subtitle?'<span class="r-chart-modal-subtitle">'+subtitle+'</span>':'';
25076 var hdr='<div class="r-modal-header"><span class="r-chart-modal-title">'+title+' \u2014 Full View</span>'+(ctrlHtml||'')+'</div>';
25077 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>';
25078 document.body.appendChild(overlay);
25079 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
25080 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
25081 return overlay.querySelector('.r-expand-modal-chart');
25082 }
25083 function capH(h){return Math.min(h,Math.max(360,Math.floor(window.innerHeight*0.82)-130));}
25084 var compExpandBtn=document.getElementById('r-composition-expand');
25085 if(compExpandBtn){compExpandBtn.addEventListener('click',function(){
25086 var mode=document.querySelector('[data-rcomp].active');var modeKey=mode?mode.getAttribute('data-rcomp'):'abs';
25087 var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
25088 var ctrlHtml='<button class="r-chart-tab'+(modeKey==='abs'?' active':'')+'" data-mcomp="abs">Absolute</button>'
25089 +'<button class="r-chart-tab'+(modeKey==='pct'?' active':'')+'" data-mcomp="pct">100% Normalized</button>';
25090 var wrap=makeExpandModal('Language Composition',mH,null,ctrlHtml);
25091 if(wrap){
25092 setTimeout(function(){renderCompositionInEl(wrap,modeKey,mH);},30);
25093 Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(btn){
25094 btn.addEventListener('click',function(){
25095 Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(b){b.classList.remove('active');});
25096 btn.classList.add('active');
25097 renderCompositionInEl(wrap,btn.getAttribute('data-mcomp'),mH);
25098 });
25099 });
25100 }
25101 });}
25102 var scatExpandBtn=document.getElementById('r-scatter-expand');
25103 if(scatExpandBtn){scatExpandBtn.addEventListener('click',function(){
25104 var wrap=makeExpandModal('Files vs Code Lines',capH(672),'File count vs SLOC per language');
25105 if(wrap)setTimeout(function(){renderScatterInEl(wrap,560);},30);
25106 });}
25107 var densExpandBtn=document.getElementById('r-density-expand');
25108 if(densExpandBtn){densExpandBtn.addEventListener('click',function(){
25109 var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
25110 var wrap=makeExpandModal('Comment Density',mH,'Comment ratio per language');
25111 if(wrap)setTimeout(function(){renderDensityInEl(wrap,mH);},30);
25112 });}
25113 var avgExpandBtn=document.getElementById('r-avglines-expand');
25114 if(avgExpandBtn){avgExpandBtn.addEventListener('click',function(){
25115 var n=LANG_D.filter(function(d){return(d.files||0)>0;}).length||1;var mH=capH(Math.max(360,n*38+60));
25116 var wrap=makeExpandModal('Avg Lines per File',mH,'Average code lines per file');
25117 if(wrap)setTimeout(function(){renderAvgLinesInEl(wrap,mH);},30);
25118 });}
25119 var subExpandBtn=document.getElementById('r-submodule-expand');
25120 if(subExpandBtn){subExpandBtn.addEventListener('click',function(){
25121 var key=subSel?subSel.value:'code';var sort=sortSel?sortSel.value:'desc';
25122 var n=(SUB_D.length+1)||1;var mH=capH(Math.max(360,n*32+100));
25123 var metCtrl=
25124 '<select class="r-chart-select" id="r-sub-modal-metric">'
25125 +'<option value="code"'+(key==='code'?' selected':'')+'>Code Lines</option>'
25126 +'<option value="comment"'+(key==='comment'?' selected':'')+'>Comments</option>'
25127 +'<option value="blank"'+(key==='blank'?' selected':'')+'>Blank Lines</option>'
25128 +'<option value="physical"'+(key==='physical'?' selected':'')+'>Physical Lines</option>'
25129 +'<option value="files"'+(key==='files'?' selected':'')+'>Files</option>'
25130 +'</select>';
25131 var sortCtrl=
25132 '<select class="r-chart-select" id="r-sub-modal-sort">'
25133 +'<option value="desc"'+(sort==='desc'?' selected':'')+'>Value \u2193</option>'
25134 +'<option value="asc"'+(sort==='asc'?' selected':'')+'>Value \u2191</option>'
25135 +'<option value="name"'+(sort==='name'?' selected':'')+'>Name A\u2192Z</option>'
25136 +'</select>';
25137 var wrap=makeExpandModal('Repository Overview',mH,null,metCtrl+sortCtrl);
25138 if(wrap){
25139 setTimeout(function(){renderSubmoduleInEl(wrap,key,sort,mH);},30);
25140 var mSub=wrap.parentNode.querySelector('#r-sub-modal-metric');
25141 var mSort=wrap.parentNode.querySelector('#r-sub-modal-sort');
25142 function reRenderSub(){renderSubmoduleInEl(wrap,mSub?mSub.value:'code',mSort?mSort.value:'desc',mH);}
25143 if(mSub)mSub.addEventListener('change',reRenderSub);
25144 if(mSort)mSort.addEventListener('change',reRenderSub);
25145 }
25146 });}
25147 })();
25148
25149 // ── Comment Density: comments / (code + comments) per language ───────────
25150 function renderDensityInEl(el,shOvr){
25151 if(!el||!LANG_D||!LANG_D.length)return;
25152 var n=LANG_D.length||1;
25153 var LW=112,SH=shOvr||Math.max(180,n*28+26);
25154 var svgW=Math.max(320,el.offsetWidth||480);
25155 var BW=Math.max(120,svgW-LW-80);
25156 var topPad=4,botPad=26;
25157 var rowTotal=Math.floor((SH-topPad-botPad)/n);
25158 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
25159 var densities=LANG_D.map(function(d){
25160 var sig=(d.code||0)+(d.comments||0);
25161 return sig>0?(d.comments||0)/sig:0;
25162 });
25163 var maxDen=Math.max.apply(null,densities)||1;
25164 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">';
25165 LANG_D.forEach(function(d,i){
25166 var den=densities[i],bw=den/maxDen*BW;
25167 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
25168 var pct=Math.round(den*100);
25169 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>';
25170 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"/>';
25171 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
25172 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>';
25173 });
25174 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>';
25175 s+='</svg>';
25176 el.innerHTML=s;
25177 }
25178 function renderDensity(){renderDensityInEl(document.getElementById('r-density-chart'),0);}
25179 renderDensity();
25180
25181 // ── Avg Lines per File: code / files per language ─────────────────────
25182 function renderAvgLinesInEl(el,shOvr){
25183 if(!el||!LANG_D||!LANG_D.length)return;
25184 var data=LANG_D.filter(function(d){return(d.files||0)>0;}).slice();
25185 data.sort(function(a,b){return(b.code/b.files)-(a.code/a.files);});
25186 var n=data.length||1;
25187 var LW=112,SH=shOvr||Math.max(180,n*28+26);
25188 var svgW=Math.max(320,el.offsetWidth||480);
25189 var BW=Math.max(120,svgW-LW-80);
25190 var topPad=4,botPad=26;
25191 var rowTotal=Math.floor((SH-topPad-botPad)/n);
25192 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
25193 var avgs=data.map(function(d){return(d.code||0)/(d.files||1);});
25194 var maxAvg=Math.max.apply(null,avgs)||1;
25195 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">';
25196 data.forEach(function(d,i){
25197 var avg=avgs[i],bw=avg/maxAvg*BW;
25198 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
25199 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>';
25200 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"/>';
25201 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
25202 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>';
25203 });
25204 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>';
25205 s+='</svg>';
25206 el.innerHTML=s;
25207 }
25208 function renderAvgLines(){renderAvgLinesInEl(document.getElementById('r-avglines-chart'),0);}
25209 renderAvgLines();
25210
25211 // ── Repository Overview: overall row + per-submodule rows ────────────
25212 function renderSubmoduleInEl(el,key,sort,shOvr){
25213 if(!el)return;
25214 var overall={
25215 name:'Overall',
25216 code:{{ code_lines }},
25217 comment:{{ comment_lines }},
25218 blank:{{ blank_lines }},
25219 physical:{{ physical_lines }},
25220 files:{{ files_analyzed }},
25221 isOverall:true
25222 };
25223 var subs=SUB_D.slice();
25224 if(sort==='desc')subs.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
25225 else if(sort==='asc')subs.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
25226 else subs.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
25227 var data=[overall].concat(subs);
25228 var sepH=subs.length>0?14:0;
25229 var naturalH=data.length*32+sepH+16;
25230 var SH=shOvr||Math.max(100,naturalH);
25231 var svgW=Math.max(320,el.offsetWidth||480);
25232 var LW=116,BW=Math.max(200,svgW-LW-54);
25233 var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
25234 var OVERALL_COL='#6b7280';
25235 var topPad=4,botPad=8;
25236 var rowSlot=Math.floor((SH-topPad-botPad-sepH)/data.length);
25237 var bH=Math.min(22,Math.max(10,Math.floor(rowSlot*0.65)));
25238 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">';
25239 var yOff=topPad;
25240 data.forEach(function(d,i){
25241 var v=d[key]||0,bw=v/maxV*BW;
25242 var y=yOff+Math.floor((rowSlot-bH)/2);
25243 var col=d.isOverall?OVERALL_COL:COLS[(i-1)%COLS.length];
25244 var label=d.name||d.path||'?';
25245 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>';
25246 if(bw>0.5)s+='<rect'+tt(label,fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+col+'" rx="3"/>';
25247 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
25248 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>';
25249 yOff+=rowSlot;
25250 if(d.isOverall&&subs.length>0){
25251 yOff+=sepH;
25252 }
25253 });
25254 s+='</svg>';
25255 el.innerHTML=s;
25256 }
25257 function renderSubmodule(key,sort){renderSubmoduleInEl(document.getElementById('r-submodule-chart'),key,sort,0);}
25258 var subSel=document.getElementById('r-sub-metric');
25259 var sortSel=document.getElementById('r-sub-sort');
25260 renderSubmodule('code','desc');
25261 if(subSel){
25262 subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');syncRowHeights();});
25263 if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);syncRowHeights();});
25264 }
25265
25266 // Equalise heights within each chart row: if one chart in a grid row is taller
25267 // than its neighbour, re-render the shorter one at the taller height so bars fill
25268 // the available vertical space instead of leaving a gap.
25269 function syncRowHeights(){
25270 var avgEl=document.getElementById('r-avglines-chart');
25271 var subEl=document.getElementById('r-submodule-chart');
25272 if(avgEl&&subEl){
25273 var avgSvg=avgEl.querySelector('svg');
25274 var subSvg=subEl.querySelector('svg');
25275 if(avgSvg&&subSvg){
25276 var avgH=parseInt(avgSvg.getAttribute('height')||'0',10);
25277 var subH=parseInt(subSvg.getAttribute('height')||'0',10);
25278 var key=subSel?subSel.value||'code':'code';
25279 var sort=sortSel?sortSel.value:'desc';
25280 if(subH>avgH+10){renderAvgLinesInEl(avgEl,subH);}
25281 else if(avgH>subH+10){renderSubmoduleInEl(subEl,key,sort,avgH);}
25282 }
25283 }
25284 var semEl=document.getElementById('r-semantic-chart');
25285 var denEl=document.getElementById('r-density-chart');
25286 if(semEl&&denEl){
25287 var semSvg=semEl.querySelector('svg');
25288 var denSvg=denEl.querySelector('svg');
25289 if(semSvg&&denSvg){
25290 var semH2=parseInt(semSvg.getAttribute('height')||'0',10);
25291 var denH2=parseInt(denSvg.getAttribute('height')||'0',10);
25292 if(denH2>semH2+10){renderSemanticInEl(semEl,semSel?semSel.value:'functions',denH2);}
25293 else if(semH2>denH2+10){renderDensityInEl(denEl,semH2);}
25294 }
25295 }
25296 }
25297 syncRowHeights();
25298
25299 // Re-render all SVG charts when the window is resized so bars fill the card.
25300 var _rResizeTimer;
25301 window.addEventListener('resize',function(){
25302 clearTimeout(_rResizeTimer);
25303 _rResizeTimer=setTimeout(function(){
25304 var rcompBtn=document.querySelector('[data-rcomp].active');
25305 renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
25306 renderScatterInEl(document.getElementById('r-scatter-chart'),0);
25307 if(semSel)renderSemantic(semSel.value||'functions');
25308 renderDensity();
25309 renderAvgLines();
25310 renderSubmodule(subSel?subSel.value||'code':'code',sortSel?sortSel.value:'desc');
25311 syncRowHeights();
25312 },120);
25313 });
25314 })();
25315
25316 (function randomizeWatermarks() {
25317 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
25318 if (!wms.length) return;
25319 var placed = [];
25320 function tooClose(top, left) {
25321 for (var i = 0; i < placed.length; i++) {
25322 var dt = Math.abs(placed[i][0] - top);
25323 var dl = Math.abs(placed[i][1] - left);
25324 if (dt < 20 && dl < 18) return true;
25325 }
25326 return false;
25327 }
25328 function pick(leftBand) {
25329 for (var attempt = 0; attempt < 50; attempt++) {
25330 var top = Math.random() * 85 + 5;
25331 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
25332 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
25333 }
25334 var top = Math.random() * 85 + 5;
25335 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
25336 placed.push([top, left]);
25337 return [top, left];
25338 }
25339 var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
25340 var half = Math.floor(wms.length / 2);
25341 wms.forEach(function (img, i) {
25342 var pos = pick(i < half);
25343 var size = Math.floor(Math.random() * 100 + 160);
25344 var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
25345 var op = (Math.random() * 0.06 + 0.07).toFixed(2);
25346 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;
25347 });
25348 })();
25349
25350 (function spawnCodeParticles() {
25351 var container = document.getElementById('code-particles');
25352 if (!container) return;
25353 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'];
25354 for (var i = 0; i < 38; i++) {
25355 (function(idx) {
25356 var el = document.createElement('span');
25357 el.className = 'code-particle';
25358 el.textContent = snippets[idx % snippets.length];
25359 var left = Math.random() * 94 + 2;
25360 var top = Math.random() * 88 + 6;
25361 var dur = (Math.random() * 10 + 9).toFixed(1);
25362 var delay = (Math.random() * 18).toFixed(1);
25363 var rot = (Math.random() * 26 - 13).toFixed(1);
25364 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
25365 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';
25366 container.appendChild(el);
25367 })(i);
25368 }
25369 })();
25370
25371 {% if pdf_generating %}
25372 // Poll for PDF readiness and swap the disabled button to a live link once done.
25373 (function() {
25374 var openBtn = document.getElementById('pdf-open-btn');
25375 var dlBtn = document.getElementById('pdf-download-btn');
25376 function checkPdf() {
25377 fetch('/api/runs/{{ run_id }}/pdf-status')
25378 .then(function(r) { return r.json(); })
25379 .then(function(d) {
25380 if (d.ready) {
25381 if (openBtn) {
25382 var a = document.createElement('a');
25383 a.className = 'button';
25384 a.id = 'pdf-open-btn';
25385 a.href = '/runs/pdf/{{ run_id }}';
25386 a.target = '_blank';
25387 a.rel = 'noopener';
25388 a.textContent = 'Open PDF';
25389 openBtn.replaceWith(a);
25390 }
25391 if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
25392 } else {
25393 setTimeout(checkPdf, 3000);
25394 }
25395 })
25396 .catch(function() { setTimeout(checkPdf, 5000); });
25397 }
25398 setTimeout(checkPdf, 3000);
25399 })();
25400 {% endif %}
25401
25402 })();
25403 </script>
25404 <script nonce="{{ csp_nonce }}">
25405 (function(){
25406 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'}];
25407 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);});}
25408 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25409 function init(){
25410 var btn=document.getElementById('settings-btn');if(!btn)return;
25411 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25412 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>';
25413 document.body.appendChild(m);
25414 var g=document.getElementById('scheme-grid');
25415 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);});
25416 var cl=document.getElementById('settings-close');
25417 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);});})();
25418 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');});
25419 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25420 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25421 }
25422 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25423 }());
25424 </script>
25425 <footer class="site-footer">
25426 local code analysis - metrics, history and reports
25427 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
25428 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25429 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25430 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25431 · <a href="/api-docs" rel="noopener">REST API</a>
25432 </footer>
25433 {% if confluence_configured %}
25434 <script nonce="{{ csp_nonce }}">
25435 (function() {
25436 var postBtn = document.getElementById('postConfluenceBtn');
25437 var copyBtn = document.getElementById('copyWikiBtn');
25438 var modal = document.getElementById('confluenceModal');
25439 if (!postBtn || !modal) return;
25440
25441 postBtn.addEventListener('click', function() {
25442 document.getElementById('confStatus').style.display = 'none';
25443 modal.style.display = 'flex';
25444 });
25445 document.getElementById('confCancelBtn').addEventListener('click', function() {
25446 modal.style.display = 'none';
25447 });
25448 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
25449
25450 document.getElementById('confSubmitBtn').addEventListener('click', async function() {
25451 var btn = this;
25452 btn.disabled = true;
25453 var status = document.getElementById('confStatus');
25454 status.style.display = 'block';
25455 status.style.background = '#dbeafe';
25456 status.style.color = '#1e40af';
25457 status.textContent = 'Posting to Confluence\u2026';
25458 var resp = await fetch('/api/confluence/post', {
25459 method: 'POST',
25460 headers: { 'Content-Type': 'application/json' },
25461 body: JSON.stringify({
25462 run_id: '{{ run_id }}',
25463 page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
25464 report_url: document.getElementById('confReportUrl').value.trim() || null
25465 })
25466 });
25467 var data = await resp.json();
25468 if (data.ok) {
25469 status.style.background = '#dcfce7'; status.style.color = '#166534';
25470 status.textContent = 'Posted! Page ID: ' + data.page_id;
25471 } else {
25472 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
25473 status.textContent = 'Error: ' + (data.error || 'Unknown error');
25474 }
25475 btn.disabled = false;
25476 });
25477
25478 if (copyBtn) {
25479 copyBtn.addEventListener('click', async function() {
25480 var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
25481 if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
25482 var text = await resp.text();
25483 try {
25484 await navigator.clipboard.writeText(text);
25485 var orig = copyBtn.textContent;
25486 copyBtn.textContent = 'Copied!';
25487 setTimeout(function() { copyBtn.textContent = orig; }, 2000);
25488 } catch(e) {
25489 alert('Clipboard write failed \u2014 check browser permissions.');
25490 }
25491 });
25492 }
25493 })();
25494 </script>
25495 {% endif %}
25496 <script nonce="{{ csp_nonce }}">
25497 (function() {
25498 var deleteBtn = document.getElementById('delete-run-btn');
25499 var modal = document.getElementById('delete-run-modal');
25500 var cancelBtn = document.getElementById('delete-run-cancel');
25501 var confirmBtn= document.getElementById('delete-run-confirm');
25502 if (!deleteBtn || !modal) return;
25503 deleteBtn.addEventListener('click', function() {
25504 document.getElementById('delete-run-status').style.display = 'none';
25505 modal.style.display = 'flex';
25506 });
25507 cancelBtn.addEventListener('click', function() { modal.style.display = 'none'; });
25508 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
25509 confirmBtn.addEventListener('click', async function() {
25510 confirmBtn.disabled = true;
25511 cancelBtn.disabled = true;
25512 var status = document.getElementById('delete-run-status');
25513 status.style.display = 'block';
25514 status.style.background = '#dbeafe'; status.style.color = '#1e40af';
25515 status.textContent = 'Deleting\u2026';
25516 try {
25517 var resp = await fetch('/api/runs/{{ run_id }}', { method: 'DELETE' });
25518 if (resp.status === 204 || resp.ok) {
25519 status.style.background = '#dcfce7'; status.style.color = '#166534';
25520 status.textContent = 'Deleted. Redirecting\u2026';
25521 setTimeout(function() { window.location.href = '/view-reports'; }, 1200);
25522 } else {
25523 var d = await resp.json().catch(function(){return {};});
25524 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
25525 status.textContent = 'Error: ' + (d.error || 'Unexpected server error');
25526 confirmBtn.disabled = false;
25527 cancelBtn.disabled = false;
25528 }
25529 } catch (e) {
25530 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
25531 status.textContent = 'Network error: ' + String(e);
25532 confirmBtn.disabled = false;
25533 cancelBtn.disabled = false;
25534 }
25535 });
25536 })();
25537 </script>
25538 <script nonce="{{ csp_nonce }}">(function(){
25539 var bundleBtn = document.getElementById('download-bundle-btn');
25540 if (bundleBtn) {
25541 bundleBtn.addEventListener('click', function() {
25542 bundleBtn.disabled = true;
25543 var orig = bundleBtn.textContent;
25544 bundleBtn.textContent = 'Preparing\u2026';
25545 fetch('/api/runs/{{ run_id }}/bundle')
25546 .then(function(r) {
25547 if (!r.ok) throw new Error('HTTP ' + r.status);
25548 return r.blob();
25549 })
25550 .then(function(blob) {
25551 var url = URL.createObjectURL(blob);
25552 var a = document.createElement('a');
25553 a.href = url;
25554 a.download = 'oxide-sloc-{{ run_id }}.tar.gz';
25555 document.body.appendChild(a);
25556 a.click();
25557 setTimeout(function() { URL.revokeObjectURL(url); document.body.removeChild(a); }, 5000);
25558 bundleBtn.disabled = false;
25559 bundleBtn.textContent = orig;
25560 })
25561 .catch(function(e) {
25562 bundleBtn.disabled = false;
25563 bundleBtn.textContent = orig;
25564 alert('Bundle download failed: ' + String(e));
25565 });
25566 });
25567 }
25568 })();</script>
25569 <script nonce="{{ csp_nonce }}">(function(){
25570 var dot=document.getElementById('status-dot');
25571 var pingEl=document.getElementById('server-ping-ms');
25572 var tipEl=document.getElementById('server-tip-ping');
25573 var fm=document.getElementById('footer-mode');
25574 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)';}}
25575 function doPing(){
25576 var t0=performance.now();
25577 fetch('/healthz',{cache:'no-store'})
25578 .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);})
25579 .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)';}});
25580 }
25581 doPing();
25582 setInterval(doPing,5000);
25583 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');}
25584 })();</script>
25585 <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>
25586 {% if let Some(banner) = report_header_footer %}
25587 <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
25588 {% endif %}
25589</body>
25590</html>
25591"##,
25592 ext = "html"
25593)]
25594#[allow(clippy::struct_excessive_bools)]
25596struct ResultTemplate {
25597 version: &'static str,
25598 report_title: String,
25599 project_path: String,
25600 output_dir: String,
25601 run_id: String,
25602 files_analyzed: u64,
25603 files_skipped: u64,
25604 physical_lines: u64,
25605 code_lines: u64,
25606 comment_lines: u64,
25607 blank_lines: u64,
25608 mixed_lines: u64,
25609 functions: u64,
25610 classes: u64,
25611 variables: u64,
25612 imports: u64,
25613 html_url: Option<String>,
25614 pdf_url: Option<String>,
25615 json_url: Option<String>,
25616 html_download_url: Option<String>,
25617 pdf_download_url: Option<String>,
25618 json_download_url: Option<String>,
25619 html_path: Option<String>,
25620 json_path: Option<String>,
25621 prev_run_id: Option<String>,
25622 prev_run_timestamp: Option<String>,
25623 prev_run_code_lines: Option<u64>,
25624 prev_fa_str: String,
25626 prev_fs_str: String,
25627 prev_pl_str: String,
25628 prev_cl_str: String,
25629 prev_cml_str: String,
25630 prev_bl_str: String,
25631 delta_fa_str: String,
25633 delta_fa_class: String,
25634 delta_fs_str: String,
25635 delta_fs_class: String,
25636 delta_pl_str: String,
25637 delta_pl_class: String,
25638 delta_cl_str: String,
25639 delta_cl_class: String,
25640 delta_cml_str: String,
25641 delta_cml_class: String,
25642 delta_bl_str: String,
25643 delta_bl_class: String,
25644 delta_lines_added: Option<i64>,
25646 delta_lines_removed: Option<i64>,
25647 delta_lines_net_str: String,
25648 delta_lines_net_class: String,
25649 delta_files_added: Option<usize>,
25650 delta_files_removed: Option<usize>,
25651 delta_files_modified: Option<usize>,
25652 delta_files_unchanged: Option<usize>,
25653 delta_files_total: Option<usize>,
25654 delta_unmodified_lines: Option<u64>,
25655 git_branch: Option<String>,
25657 git_branch_url: Option<String>,
25658 git_commit: Option<String>,
25659 git_commit_long: Option<String>,
25660 git_author: Option<String>,
25661 git_commit_url: Option<String>,
25662 scan_performed_by: String,
25664 scan_time_display: String,
25665 scan_time_utc_ms: i64,
25666 os_display: String,
25667 test_count: u64,
25668 test_assertion_count: u64,
25670 prev_scan_count: usize,
25672 current_scan_number: usize,
25673 submodule_rows: Vec<SubmoduleRow>,
25675 scan_config_url: String,
25676 lang_chart_json: String,
25677 #[allow(dead_code)]
25679 scatter_chart_json: String,
25680 #[allow(dead_code)]
25681 semantic_chart_json: String,
25682 #[allow(dead_code)]
25683 submodule_chart_json: String,
25684 #[allow(dead_code)]
25685 has_submodule_data: bool,
25686 #[allow(dead_code)]
25687 has_semantic_data: bool,
25688 pdf_generating: bool,
25689 csp_nonce: String,
25690 confluence_configured: bool,
25692 server_mode: bool,
25693 report_header_footer: Option<String>,
25695 run_id_short: String,
25696 #[allow(dead_code)]
25698 is_offline: bool,
25699 cyclomatic_complexity: u64,
25701 lsloc: Option<u64>,
25703 uloc: u64,
25705 dryness_pct_str: String,
25707 duplicate_group_count: usize,
25709 has_cocomo: bool,
25711 cocomo_effort_str: String,
25713 cocomo_duration_str: String,
25715 cocomo_staff_str: String,
25717 cocomo_ksloc_str: String,
25719 cocomo_mode_label: String,
25721 cocomo_mode_tooltip: String,
25723 complexity_alert: u32,
25725 has_coverage_data: bool,
25727 cov_line_pct: String,
25729 cov_fn_pct: String,
25731 cov_branch_pct: String,
25733 cov_lines_summary: String,
25735}
25736
25737#[derive(Template)]
25738#[template(
25739 source = r##"
25740<!doctype html>
25741<html lang="en">
25742<head>
25743 <meta charset="utf-8">
25744 <meta name="viewport" content="width=device-width, initial-scale=1">
25745 <title>OxideSLOC | Analyzing…</title>
25746 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
25747 <style nonce="{{ csp_nonce }}">
25748 :root {
25749 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
25750 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
25751 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
25752 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
25753 }
25754 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
25755 *{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;}
25756 .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);}
25757 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
25758 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
25759 .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));}
25760 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
25761 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
25762 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
25763 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
25764 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
25765 @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; } }
25766 .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;}
25767 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
25768 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
25769 .page-body{padding:32px 24px 36px;}
25770 .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
25771 .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;}
25772 .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
25773 @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
25774 .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
25775 .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
25776 .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;}
25777 .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
25778 .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;}
25779 .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
25780 .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
25781 .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
25782 .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;}
25783 @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
25784 .hidden{display:none!important;}
25785 .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;}
25786 .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;}
25787 .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
25788 .err-panel p{margin:0;font-size:13px;color:var(--muted);}
25789 .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
25790 .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);}
25791 .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
25792 .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;}
25793 .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
25794 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25795 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
25796 @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
25797 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25798 .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;}
25799 @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));}}
25800 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
25801 .site-footer a{color:var(--muted);}
25802 .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;}
25803 .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
25804 body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
25805 body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
25806 </style>
25807</head>
25808<body>
25809 <div class="background-watermarks" aria-hidden="true">
25810 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25811 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25812 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25813 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25814 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25815 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25816 </div>
25817 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
25818 <nav class="top-nav">
25819 <div class="top-nav-inner">
25820 <a href="/" class="brand">
25821 <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
25822 <div class="brand-copy">
25823 <h1 class="brand-title">OxideSLOC</h1>
25824 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
25825 </div>
25826 </a>
25827 <div class="nav-right">
25828 <a class="nav-pill" href="/">Home</a>
25829 <div class="nav-dropdown">
25830 <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>
25831 <div class="nav-dropdown-menu">
25832 <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>
25833 </div>
25834 </div>
25835 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
25836 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
25837 <div class="nav-dropdown">
25838 <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>
25839 <div class="nav-dropdown-menu">
25840 <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>
25841 </div>
25842 </div>
25843 <div class="server-status-wrap" id="server-status-wrap">
25844 <div class="nav-pill server-online-pill" id="server-status-pill">
25845 <span class="status-dot" id="status-dot"></span>
25846 <span id="server-status-label">Server</span>
25847 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
25848 </div>
25849 <div class="server-status-tip">
25850 OxideSLOC is running — accessible on your network.
25851 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
25852 </div>
25853 </div>
25854 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
25855 <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>
25856 </button>
25857 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
25858 <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>
25859 <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>
25860 </button>
25861 </div>
25862 </div>
25863 </nav>
25864 <div class="page-body">
25865 <div class="wait-panel">
25866 <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
25867 <h2 class="wait-title">Analyzing your project…</h2>
25868 <p class="wait-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
25869 <div class="path-block">{{ project_path }}</div>
25870 <div class="metrics-row">
25871 <div class="metric-card">
25872 <div class="metric-label">Elapsed</div>
25873 <div class="metric-value" id="elapsed">0s</div>
25874 </div>
25875 <div class="metric-card">
25876 <div class="metric-label">Phase</div>
25877 <div class="metric-value" id="phase">Starting</div>
25878 </div>
25879 <div class="metric-card hidden" id="files-card">
25880 <div class="metric-label">Files</div>
25881 <div class="metric-value" id="files-progress">0</div>
25882 </div>
25883 </div>
25884 <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
25885 <div class="warn-slow hidden" id="warn-slow">
25886 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.
25887 </div>
25888 <div class="err-panel hidden" id="err-panel">
25889 <strong>Analysis failed</strong>
25890 <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
25891 </div>
25892 <div class="actions hidden" id="actions">
25893 <a href="/scan" class="btn-primary">Try Again</a>
25894 <a href="/view-reports" class="btn-outline">View Reports</a>
25895 </div>
25896 </div>
25897 </div>
25898 <script nonce="{{ csp_nonce }}">
25899 (function() {
25900 var WAIT_ID = {{ wait_id_json|safe }};
25901 var startTime = Date.now();
25902 var pollInterval = 1500;
25903 var retries = 0;
25904 var maxRetries = 5;
25905 var warnShown = false;
25906
25907 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();}
25908
25909 function elapsed() {
25910 return Math.floor((Date.now() - startTime) / 1000);
25911 }
25912
25913 function updateElapsed() {
25914 var s = elapsed();
25915 document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
25916 }
25917
25918 function setPhase(txt) {
25919 document.getElementById('phase').textContent = txt;
25920 }
25921
25922 var elapsedTimer = setInterval(updateElapsed, 1000);
25923
25924 function poll() {
25925 fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
25926 .then(function(r) {
25927 if (!r.ok) throw new Error('HTTP ' + r.status);
25928 return r.json();
25929 })
25930 .then(function(data) {
25931 retries = 0;
25932 if (data.state === 'complete') {
25933 clearInterval(elapsedTimer);
25934 setPhase('Done');
25935 window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
25936 } else if (data.state === 'failed') {
25937 clearInterval(elapsedTimer);
25938 setPhase('Failed');
25939 document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
25940 document.getElementById('err-panel').classList.remove('hidden');
25941 document.getElementById('actions').classList.remove('hidden');
25942 } else {
25943 // still running
25944 var s = elapsed();
25945 if (s > 90 && !warnShown) {
25946 warnShown = true;
25947 document.getElementById('warn-slow').classList.remove('hidden');
25948 }
25949 setPhase(data.phase || 'Running');
25950 var fd = data.files_done || 0, ft = data.files_total || 0;
25951 if (ft > 0) {
25952 var card = document.getElementById('files-card');
25953 if (card) card.classList.remove('hidden');
25954 var fp = document.getElementById('files-progress');
25955 if (fp) fp.textContent = fmt(fd) + ' / ' + fmt(ft);
25956 }
25957 setTimeout(poll, pollInterval);
25958 }
25959 })
25960 .catch(function(err) {
25961 retries++;
25962 if (retries >= maxRetries) {
25963 clearInterval(elapsedTimer);
25964 document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
25965 document.getElementById('err-panel').classList.remove('hidden');
25966 document.getElementById('actions').classList.remove('hidden');
25967 } else {
25968 // exponential back-off capped at 8s
25969 setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
25970 }
25971 });
25972 }
25973
25974 setTimeout(poll, pollInterval);
25975
25976 // If the browser restores this page from bfcache (Back after viewing results),
25977 // timers may be frozen; kick off a fresh poll so we either redirect or resume.
25978 window.addEventListener("pageshow", function(e) {
25979 if (e.persisted) { setTimeout(poll, 200); }
25980 });
25981 })();
25982 </script>
25983 <footer class="site-footer">
25984 local code analysis - metrics, history and reports
25985 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
25986 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25987 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25988 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25989 · <a href="/api-docs" rel="noopener">REST API</a>
25990 </footer>
25991 <script nonce="{{ csp_nonce }}">
25992 (function(){
25993 var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
25994 if(s==="dark")b.classList.add("dark-theme");
25995 var tt=document.getElementById("theme-toggle");
25996 if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
25997 })();
25998 (function spawnCodeParticles(){
25999 var c=document.getElementById('code-particles');if(!c)return;
26000 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'];
26001 for(var i=0;i<32;i++){(function(idx){
26002 var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length];
26003 var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
26004 var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
26005 var rot=(Math.random()*26-13).toFixed(1),op=(Math.random()*0.09+0.06).toFixed(3);
26006 el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
26007 el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
26008 c.appendChild(el);
26009 })(i);}
26010 })();
26011 (function randomizeWatermarks(){
26012 var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
26013 var placed=[];
26014 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;}
26015 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];}
26016 var half=Math.floor(wms.length/2);
26017 wms.forEach(function(img,i){
26018 var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
26019 var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
26020 var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
26021 img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
26022 img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
26023 img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
26024 });
26025 })();
26026 </script>
26027 <script nonce="{{ csp_nonce }}">
26028 (function(){
26029 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'}];
26030 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);});}
26031 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26032 function init(){
26033 var btn=document.getElementById('settings-btn');if(!btn)return;
26034 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
26035 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>';
26036 document.body.appendChild(m);
26037 var g=document.getElementById('scheme-grid');
26038 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);});
26039 var cl=document.getElementById('settings-close');
26040 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);});})();
26041 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');});
26042 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
26043 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
26044 }
26045 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26046 }());
26047 </script>
26048 <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]';
26049 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;}
26050 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>
26051</body>
26052</html>
26053"##,
26054 ext = "html"
26055)]
26056struct ScanWaitTemplate {
26057 version: &'static str,
26058 wait_id_json: String,
26059 project_path: String,
26060 csp_nonce: String,
26061}
26062
26063#[derive(Template)]
26064#[template(
26065 source = r##"
26066<!doctype html>
26067<html lang="en">
26068<head>
26069 <meta charset="utf-8">
26070 <meta name="viewport" content="width=device-width, initial-scale=1">
26071 <title>OxideSLOC | Error</title>
26072 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26073 <style nonce="{{ csp_nonce }}">
26074 :root {
26075 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
26076 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
26077 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
26078 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
26079 }
26080 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
26081 *{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;}
26082 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26083 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26084 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
26085 .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);}
26086 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26087 .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));}
26088 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26089 .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;}
26090 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26091 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
26092 @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; } }
26093 .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;}
26094 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26095 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26096 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26097 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26098 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
26099 .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;}
26100 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26101 .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);}
26102 .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;}
26103 .settings-close:hover{color:var(--text);background:var(--surface-2);}
26104 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26105 .settings-modal-body{padding:14px 16px 16px;}
26106 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26107 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26108 .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;}
26109 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26110 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26111 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26112 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26113 .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;}
26114 .tz-select:focus{border-color:var(--oxide);}
26115 .page{width:100%;max-width:1720px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
26116 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
26117 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
26118 h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
26119 .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;}
26120 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
26121 .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);}
26122 .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;}
26123 .btn-secondary:hover{background:var(--line);}
26124 .bug-report-section{margin-top:28px;padding-top:22px;border-top:1px solid var(--line);}
26125 .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;}
26126 .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;}
26127 .bug-report-trigger .br-icon{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;flex-shrink:0;}
26128 .bug-report-trigger .br-chevron{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;transition:transform .2s ease;margin-left:2px;}
26129 .bug-report-trigger.open .br-chevron{transform:rotate(180deg);}
26130 .bug-report-panel{display:none;flex-direction:column;gap:12px;margin-top:18px;}
26131 .bug-report-panel.open{display:flex;}
26132 .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;}
26133 .br-network-badge.online{background:#e8f5ee;color:#2a6846;}
26134 .br-network-badge.offline{background:#fff4e5;color:#9a5b00;}
26135 body.dark-theme .br-network-badge.online{background:#1a3d2b;color:#5aba8a;}
26136 body.dark-theme .br-network-badge.offline{background:#3d2a00;color:#f0a940;}
26137 .br-net-dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0;}
26138 .br-network-badge.online .br-net-dot{background:#2a6846;}
26139 .br-network-badge.offline .br-net-dot{background:#9a5b00;}
26140 body.dark-theme .br-network-badge.online .br-net-dot{background:#5aba8a;}
26141 body.dark-theme .br-network-badge.offline .br-net-dot{background:#f0a940;}
26142 .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;}
26143 .bug-report-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
26144 .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;}
26145 .btn-sm:hover{background:var(--line);}
26146 .btn-sm svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;}
26147 .bug-report-hint{font-size:11px;color:var(--muted);line-height:1.5;}
26148 .bug-report-hint a{color:var(--oxide);text-decoration:none;font-weight:700;}
26149 .bug-report-hint a:hover{text-decoration:underline;}
26150 .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;}
26151 .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
26152 .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;}
26153 .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;}
26154 .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;}
26155 @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));}}
26156 .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;}
26157 </style>
26158</head>
26159<body>
26160 <div class="background-watermarks" aria-hidden="true">
26161 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26162 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26163 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26164 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26165 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26166 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26167 </div>
26168 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26169 <div class="top-nav">
26170 <div class="top-nav-inner">
26171 <a class="brand" href="/">
26172 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
26173 <div class="brand-copy">
26174 <div class="brand-title">OxideSLOC</div>
26175 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26176 </div>
26177 </a>
26178 <div class="nav-right">
26179 <a class="nav-pill" href="/">Home</a>
26180 <div class="nav-dropdown">
26181 <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>
26182 <div class="nav-dropdown-menu">
26183 <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>
26184 </div>
26185 </div>
26186 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
26187 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26188 <div class="nav-dropdown">
26189 <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>
26190 <div class="nav-dropdown-menu">
26191 <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>
26192 </div>
26193 </div>
26194 <div class="server-status-wrap" id="server-status-wrap">
26195 <div class="nav-pill server-online-pill" id="server-status-pill">
26196 <span class="status-dot" id="status-dot"></span>
26197 <span id="server-status-label">Server</span>
26198 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26199 </div>
26200 <div class="server-status-tip">
26201 OxideSLOC is running — accessible on your network.
26202 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26203 </div>
26204 </div>
26205 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26206 <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>
26207 </button>
26208 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26209 <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>
26210 <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>
26211 </button>
26212 </div>
26213 </div>
26214 </div>
26215
26216 <div class="page">
26217 <div class="panel">
26218 <h1>Error</h1>
26219 <div class="error-box" id="error-msg-text">{{ message }}</div>
26220 <div id="br-meta" hidden
26221 data-version="{{ version }}"
26222 data-run-id="{% if let Some(rid) = run_id %}{{ rid }}{% endif %}"
26223 data-error-code="{% if let Some(code) = error_code %}{{ code }}{% endif %}"></div>
26224 <div class="actions">
26225 <a class="btn-primary" href="/scan">Back to setup</a>
26226 {% if let Some(report_url) = last_report_url %}
26227 <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
26228 {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
26229 {% else %}
26230 <a class="btn-secondary" href="/view-reports">View Reports</a>
26231 {% endif %}
26232 </div>
26233 <div class="bug-report-section" id="bug-report-section">
26234 <button type="button" class="bug-report-trigger" id="bug-report-trigger" aria-expanded="false" aria-controls="bug-report-panel">
26235 <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>
26236 Generate Bug Report
26237 <svg class="br-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
26238 </button>
26239 <div class="bug-report-panel" id="bug-report-panel" role="region" aria-label="Bug report">
26240 <div class="br-network-badge" id="br-network-badge"><span class="br-net-dot"></span><span id="br-network-label">Checking…</span></div>
26241 <pre class="bug-report-pre" id="bug-report-pre">Collecting info…</pre>
26242 <div class="bug-report-btns">
26243 <button type="button" class="btn-sm" id="bug-report-copy">
26244 <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>
26245 Copy to clipboard
26246 </button>
26247 <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;">
26248 <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>
26249 Open GitHub Issue
26250 </a>
26251 <button type="button" class="btn-sm" id="bug-report-save" style="display:none;">
26252 <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>
26253 Save as file
26254 </button>
26255 </div>
26256 <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>
26257 <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>
26258 </div>
26259 </div>
26260 </div>
26261 </div>
26262 <footer class="site-footer">
26263 oxide-sloc v{{ version }} — local code metrics workbench ·
26264 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26265 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26266 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26267 · <a href="/api-docs" rel="noopener">REST API</a>
26268 </footer>
26269 <script nonce="{{ csp_nonce }}">(function(){
26270 var meta=document.getElementById('br-meta');
26271 var pre=document.getElementById('bug-report-pre');
26272 var copyBtn=document.getElementById('bug-report-copy');
26273 var trigger=document.getElementById('bug-report-trigger');
26274 var panel=document.getElementById('bug-report-panel');
26275 var networkBadge=document.getElementById('br-network-badge');
26276 var networkLabel=document.getElementById('br-network-label');
26277 var ghLink=document.getElementById('bug-report-github-link');
26278 var saveBtn=document.getElementById('bug-report-save');
26279 var hintOnline=document.getElementById('br-hint-online');
26280 var hintOffline=document.getElementById('br-hint-offline');
26281 if(!meta||!pre)return;
26282 var ver=meta.getAttribute('data-version')||'';
26283 var runId=meta.getAttribute('data-run-id')||'';
26284 var code=meta.getAttribute('data-error-code')||'';
26285 var msgEl=document.getElementById('error-msg-text');
26286 var msg=msgEl?msgEl.textContent.trim():'';
26287 function getBrowser(){
26288 var ua=navigator.userAgent;
26289 var m=ua.match(/(Edg|OPR|Chrome|Firefox|Safari)\/(\d+)/);
26290 if(!m)return 'Unknown browser';
26291 var n={'Edg':'Edge','OPR':'Opera'}[m[1]]||m[1];
26292 return n+' '+m[2];
26293 }
26294 var lines=['oxide-sloc Bug Report','==============================',''];
26295 lines.push('App version: v'+ver);
26296 if(code)lines.push('HTTP status: '+code);
26297 if(runId)lines.push('Run ID: '+runId);
26298 lines.push('Page: '+window.location.pathname+(window.location.search||''));
26299 lines.push('Timestamp: '+new Date().toISOString());
26300 lines.push('Browser: '+getBrowser());
26301 lines.push('Viewport: '+window.innerWidth+'x'+window.innerHeight);
26302 lines.push('');
26303 lines.push('Error message:');
26304 lines.push(msg);
26305 lines.push('');
26306 lines.push('Steps to reproduce:');
26307 lines.push(' 1. ');
26308 lines.push('');
26309 lines.push('Expected behavior:');
26310 lines.push(' ');
26311 pre.textContent=lines.join('\n');
26312 function applyNetwork(online){
26313 if(networkBadge){networkBadge.style.display='inline-flex';networkBadge.className='br-network-badge '+(online?'online':'offline');}
26314 if(networkLabel)networkLabel.textContent=online?'Internet connected':'Air-gapped / offline';
26315 if(ghLink){
26316 if(online){
26317 var body=encodeURIComponent(pre.textContent+'\n\n---\n*Generated by oxide-sloc v'+ver+'*');
26318 ghLink.href='https://github.com/oxide-sloc/oxide-sloc/issues/new?title=Bug+Report&body='+body;
26319 }
26320 ghLink.style.display=online?'inline-flex':'none';
26321 }
26322 if(saveBtn)saveBtn.style.display=online?'none':'inline-flex';
26323 if(hintOnline)hintOnline.style.display=online?'block':'none';
26324 if(hintOffline)hintOffline.style.display=online?'none':'block';
26325 }
26326 applyNetwork(navigator.onLine);
26327 var probed=false;
26328 function probeNetwork(){
26329 if(probed)return;probed=true;
26330 var probeUrls=['https://github.com','https://www.google.com','https://www.cloudflare.com'];
26331 var probeIdx=0;
26332 function tryNext(){
26333 if(probeIdx>=probeUrls.length){applyNetwork(false);return;}
26334 var u=probeUrls[probeIdx++];
26335 var c2=new AbortController();
26336 var t2=setTimeout(function(){c2.abort();},4000);
26337 fetch(u,{mode:'no-cors',cache:'no-store',signal:c2.signal})
26338 .then(function(){clearTimeout(t2);applyNetwork(true);})
26339 .catch(function(){clearTimeout(t2);tryNext();});
26340 }
26341 tryNext();
26342 }
26343 if(trigger&&panel){
26344 trigger.addEventListener('click',function(){
26345 var open=panel.classList.toggle('open');
26346 trigger.classList.toggle('open',open);
26347 trigger.setAttribute('aria-expanded',open?'true':'false');
26348 if(open)probeNetwork();
26349 });
26350 }
26351 if(copyBtn){
26352 copyBtn.addEventListener('click',function(){
26353 var txt=pre.textContent;
26354 if(navigator.clipboard&&navigator.clipboard.writeText){
26355 navigator.clipboard.writeText(txt).then(function(){
26356 copyBtn.textContent='\u2713 Copied!';
26357 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);
26358 });
26359 }else{
26360 var ta=document.createElement('textarea');
26361 ta.value=txt;ta.style.position='fixed';ta.style.opacity='0';
26362 document.body.appendChild(ta);ta.select();
26363 try{document.execCommand('copy');copyBtn.textContent='\u2713 Copied!';}catch(e){}
26364 document.body.removeChild(ta);
26365 }
26366 });
26367 }
26368 if(saveBtn){
26369 saveBtn.addEventListener('click',function(){
26370 var txt=pre.textContent;
26371 var blob=new Blob([txt],{type:'text/plain'});
26372 var url=URL.createObjectURL(blob);
26373 var a=document.createElement('a');
26374 a.href=url;a.download='oxide-sloc-bug-report-'+new Date().toISOString().slice(0,10)+'.txt';
26375 document.body.appendChild(a);a.click();
26376 document.body.removeChild(a);URL.revokeObjectURL(url);
26377 });
26378 }
26379 })();</script>
26380 <script nonce="{{ csp_nonce }}">
26381 (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");});})();
26382 (function spawnCodeParticles() {
26383 var container = document.getElementById('code-particles');
26384 if (!container) return;
26385 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'];
26386 for (var i = 0; i < 38; i++) {
26387 (function(idx) {
26388 var el = document.createElement('span');
26389 el.className = 'code-particle';
26390 el.textContent = snippets[idx % snippets.length];
26391 var left = Math.random() * 94 + 2;
26392 var top = Math.random() * 88 + 6;
26393 var dur = (Math.random() * 10 + 9).toFixed(1);
26394 var delay = (Math.random() * 18).toFixed(1);
26395 var rot = (Math.random() * 26 - 13).toFixed(1);
26396 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
26397 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';
26398 container.appendChild(el);
26399 })(i);
26400 }
26401 })();
26402 (function randomizeWatermarks() {
26403 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
26404 var placed = [];
26405 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; }
26406 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]; }
26407 var half = Math.floor(wms.length/2);
26408 wms.forEach(function(img, i) {
26409 var pos = pick(i < half);
26410 var w = Math.floor(Math.random()*60+80);
26411 var rot = (Math.random()*40-20).toFixed(1);
26412 var op = (Math.random()*0.08+0.05).toFixed(2);
26413 var animDur = (Math.random()*6+5).toFixed(1);
26414 var animDelay = (Math.random()*10).toFixed(1);
26415 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';
26416 });
26417 })();
26418 </script>
26419 <script nonce="{{ csp_nonce }}">
26420 (function(){
26421 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'}];
26422 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);});}
26423 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26424 function init(){
26425 var btn=document.getElementById('settings-btn');if(!btn)return;
26426 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
26427 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>';
26428 document.body.appendChild(m);
26429 var g=document.getElementById('scheme-grid');
26430 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);});
26431 var cl=document.getElementById('settings-close');
26432 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);});})();
26433 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');});
26434 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
26435 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
26436 }
26437 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26438 }());
26439 </script>
26440 <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]';
26441 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;}
26442 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>
26443</body>
26444</html>
26445"##,
26446 ext = "html"
26447)]
26448struct ErrorTemplate {
26449 message: String,
26450 last_report_url: Option<String>,
26452 last_report_label: Option<String>,
26454 run_id: Option<String>,
26456 error_code: Option<u16>,
26458 csp_nonce: String,
26459 version: &'static str,
26460}
26461
26462#[derive(Template)]
26465#[template(
26466 source = r##"
26467<!doctype html>
26468<html lang="en">
26469<head>
26470 <meta charset="utf-8">
26471 <meta name="viewport" content="width=device-width, initial-scale=1">
26472 <title>OxideSLOC | Locate Report</title>
26473 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26474 <style nonce="{{ csp_nonce }}">
26475 :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);}
26476 body.dark-theme{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;--line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;--muted-2:#9c877a;}
26477 *{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;}
26478 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26479 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26480 .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);}
26481 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26482 .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));}
26483 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26484 .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;}
26485 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26486 @media(max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
26487 @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;}}
26488 .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;}
26489 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26490 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26491 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26492 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26493 .theme-toggle .icon-sun{display:none;}body.dark-theme .theme-toggle .icon-sun{display:block;}body.dark-theme .theme-toggle .icon-moon{display:none;}
26494 .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;}
26495 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26496 .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);}
26497 .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;}
26498 .settings-close:hover{color:var(--text);background:var(--surface-2);}
26499 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26500 .settings-modal-body{padding:14px 16px 16px;}
26501 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26502 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26503 .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;}
26504 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26505 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26506 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26507 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26508 .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;}
26509 .tz-select:focus{border-color:var(--oxide);}
26510 .page{width:100%;max-width:1404px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
26511 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
26512 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
26513 .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 20px;line-height:1.55;}
26514 .field-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:6px;}
26515 .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;}
26516 .filename-chip svg{flex:0 0 auto;opacity:0.6;}
26517 .locate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
26518 .locate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
26519 .locate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
26520 .locate-row{display:flex;gap:8px;align-items:stretch;}
26521 .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;}
26522 .locate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
26523 body.dark-theme .locate-input{background:var(--surface-2);}
26524 .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;}
26525 .warning-banner.show{display:flex;}
26526 .warning-banner svg{flex:0 0 auto;}
26527 body.dark-theme .warning-banner{background:#3d2800;border-color:#a06820;color:#ffcf7a;}
26528 .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;}
26529 .error-inline.show{display:flex;}
26530 .error-inline svg{flex:0 0 auto;margin-top:2px;}
26531 body.dark-theme .error-inline{background:#4a1e1e;border-color:#b85555;color:#ffb3b3;}
26532 .err-kv{border-collapse:collapse;margin:6px 0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;}
26533 .err-kv-k{padding:2px 14px 2px 0;font-weight:700;white-space:nowrap;vertical-align:top;opacity:.85;}
26534 .err-kv-v{padding:2px 0;word-break:break-all;vertical-align:top;}
26535 .err-kv-p{margin:0 0 4px;}
26536 .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;}
26537 .success-inline.show{display:flex;}
26538 body.dark-theme .success-inline{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
26539 .folder-hint-shell{border:1px solid var(--line);border-radius:14px;overflow:hidden;background:var(--surface);margin-top:20px;}
26540 .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;}
26541 body.dark-theme .folder-hint-hdr{background:linear-gradient(180deg,var(--surface-2),rgba(0,0,0,0.12));}
26542 .folder-hint-body{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;}
26543 .fh-row{display:flex;align-items:center;gap:6px;padding:7px 14px;border-bottom:1px solid rgba(0,0,0,0.04);}
26544 .fh-row:nth-child(odd){background:rgba(255,255,255,0.25);}
26545 body.dark-theme .fh-row:nth-child(odd){background:rgba(255,255,255,0.02);}
26546 .fh-row:last-child{border-bottom:none;}
26547 .fh-i1{padding-left:36px;}.fh-i2{padding-left:58px;}
26548 .fh-dir{font-weight:800;color:var(--text);}
26549 .fh-hl{color:var(--oxide);font-weight:700;}
26550 .fh-muted{color:var(--muted);}
26551 .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;}
26552 body.dark-theme .fh-badge{background:rgba(255,140,90,0.15);border-color:rgba(255,140,90,0.30);}
26553 .fh-tog{color:var(--muted-2);font-size:13px;flex:0 0 14px;}
26554 .fh-bul{color:var(--muted-2);font-size:8px;flex:0 0 14px;text-align:center;opacity:0.5;}
26555 .btn-row{margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
26556 .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;}
26557 .btn-primary:disabled{opacity:0.4;cursor:not-allowed;box-shadow:none;}
26558 .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;}
26559 .btn-secondary:hover{background:var(--line);}
26560 .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;}
26561 .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;}
26562 .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;}
26563 @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));}}
26564 .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;}
26565 .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;}
26566 .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
26567 </style>
26568</head>
26569<body>
26570 <div class="background-watermarks" aria-hidden="true">
26571 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26572 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26573 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26574 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26575 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26576 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26577 </div>
26578 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26579 <div class="top-nav">
26580 <div class="top-nav-inner">
26581 <a class="brand" href="/">
26582 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
26583 <div class="brand-copy">
26584 <div class="brand-title">OxideSLOC</div>
26585 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26586 </div>
26587 </a>
26588 <div class="nav-right">
26589 <a class="nav-pill" href="/">Home</a>
26590 <div class="nav-dropdown">
26591 <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>
26592 <div class="nav-dropdown-menu">
26593 <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>
26594 </div>
26595 </div>
26596 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
26597 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26598 <div class="nav-dropdown">
26599 <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>
26600 <div class="nav-dropdown-menu">
26601 <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>
26602 </div>
26603 </div>
26604 <div class="server-status-wrap" id="server-status-wrap">
26605 <div class="nav-pill server-online-pill" id="server-status-pill">
26606 <span class="status-dot" id="status-dot"></span>
26607 <span id="server-status-label">Server</span>
26608 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26609 </div>
26610 <div class="server-status-tip">
26611 OxideSLOC is running — accessible on your network.
26612 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26613 </div>
26614 </div>
26615 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26616 <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>
26617 </button>
26618 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26619 <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>
26620 <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>
26621 </button>
26622 </div>
26623 </div>
26624 </div>
26625
26626 <div class="page">
26627 <div id="locate-meta" hidden data-expected="{{ expected_filename }}" data-run-id="{{ run_id }}" data-redirect="/runs/{{ artifact_type }}/{{ run_id }}"></div>
26628 <div class="panel">
26629 <h1>Report File Not Found</h1>
26630 <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>
26631 <div class="field-label">Missing file</div>
26632 <div class="filename-chip">
26633 <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>
26634 {{ expected_filename }}
26635 </div>
26636 <div class="locate-section">
26637 <h2>Locate Scan Output Folder</h2>
26638 <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>
26639 <p>OxideSLOC will find the correct files inside automatically.</p>
26640 <div class="locate-row">
26641 <input type="text" id="locate-file-input"
26642 placeholder="e.g. C:\Desktop\over-here\project_20260601-0029-…"
26643 class="locate-input" autocomplete="off" spellcheck="false">
26644 {% if !server_mode %}
26645 <button type="button" id="browse-locate-btn" class="btn-secondary">Browse…</button>
26646 {% endif %}
26647 </div>
26648 <div class="warning-banner" id="filename-warning">
26649 <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>
26650 <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>
26651 </div>
26652 <div class="error-inline" id="locate-error">
26653 <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>
26654 <span id="locate-error-text"></span>
26655 </div>
26656 <div class="success-inline" id="locate-success">
26657 <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>
26658 <span>Scan restored — loading report…</span>
26659 </div>
26660 <div class="btn-row">
26661 <button type="button" id="locate-submit-btn" class="btn-primary" disabled>Restore Report</button>
26662 <a class="btn-secondary" href="/view-reports">View Reports</a>
26663 </div>
26664 <div class="folder-hint-shell">
26665 <div class="folder-hint-hdr">
26666 <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>
26667 Expected Folder Structure — Select the Top-Level Folder
26668 </div>
26669 <div class="folder-hint-body">
26670 <div class="fh-row">
26671 <span class="fh-tog">►</span>
26672 <span class="fh-dir">project_20260601-0029-…/</span>
26673 <span class="fh-badge">← select this</span>
26674 </div>
26675 <div class="fh-row fh-i1">
26676 <span class="fh-tog">►</span>
26677 <span class="fh-dir">html/</span>
26678 </div>
26679 <div class="fh-row fh-i2">
26680 <span class="fh-bul">•</span>
26681 <span class="fh-hl">{{ expected_filename }}</span>
26682 </div>
26683 <div class="fh-row fh-i1">
26684 <span class="fh-tog">►</span>
26685 <span class="fh-dir">json/</span>
26686 </div>
26687 <div class="fh-row fh-i2">
26688 <span class="fh-bul">•</span>
26689 <span class="fh-muted">result_*.json</span>
26690 </div>
26691 <div class="fh-row fh-i1">
26692 <span class="fh-tog">►</span>
26693 <span class="fh-dir">pdf/</span>
26694 </div>
26695 <div class="fh-row fh-i2">
26696 <span class="fh-bul">•</span>
26697 <span class="fh-muted">report_*.pdf</span>
26698 </div>
26699 <div class="fh-row fh-i1">
26700 <span class="fh-tog">►</span>
26701 <span class="fh-dir">excel/</span>
26702 </div>
26703 <div class="fh-row fh-i2">
26704 <span class="fh-bul">•</span>
26705 <span class="fh-muted">report_*.csv report_*.xlsx</span>
26706 </div>
26707 </div>
26708 </div>
26709 </div>
26710 </div>
26711 </div>
26712 <footer class="site-footer">
26713 oxide-sloc v{{ version }} — local code metrics workbench ·
26714 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26715 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26716 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26717 · <a href="/api-docs" rel="noopener">REST API</a>
26718 </footer>
26719 <script nonce="{{ csp_nonce }}">(function(){
26720 var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
26721 if(s==="dark")b.classList.add("dark-theme");
26722 document.getElementById("theme-toggle").addEventListener("click",function(){
26723 var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");
26724 });
26725 })();</script>
26726 <script nonce="{{ csp_nonce }}">(function spawnCodeParticles(){
26727 var c=document.getElementById('code-particles');if(!c)return;
26728 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'];
26729 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);}
26730 })();
26731 (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>
26732 <script nonce="{{ csp_nonce }}">(function(){
26733 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'}];
26734 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);});}
26735 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26736 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');});}
26737 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26738 }());</script>
26739 <script nonce="{{ csp_nonce }}">(function(){
26740 var meta=document.getElementById('locate-meta');
26741 var inp=document.getElementById('locate-file-input');
26742 var browseBtn=document.getElementById('browse-locate-btn');
26743 var submitBtn=document.getElementById('locate-submit-btn');
26744 var warning=document.getElementById('filename-warning');
26745 var errBox=document.getElementById('locate-error');
26746 var errText=document.getElementById('locate-error-text');
26747 var okBox=document.getElementById('locate-success');
26748 var expected=meta?meta.getAttribute('data-expected'):'';
26749 var runId=meta?meta.getAttribute('data-run-id'):'';
26750 var redirectUrl=meta?meta.getAttribute('data-redirect'):'/view-reports';
26751 function basename(p){return p.replace(/\\/g,'/').split('/').pop()||'';}
26752 function showErr(msg){
26753 if(errText){
26754 errText.innerHTML='';
26755 var lines=msg.split('\n');
26756 var hasPairs=lines.some(function(l){return / : /.test(l);});
26757 if(!hasPairs){errText.textContent=msg;}
26758 else{
26759 var frag=document.createDocumentFragment();var tbl=null;
26760 lines.forEach(function(line){
26761 var m=line.match(/^(.*?) : (.*)$/);
26762 if(m){
26763 if(!tbl){tbl=document.createElement('table');tbl.className='err-kv';frag.appendChild(tbl);}
26764 var tr=document.createElement('tr');
26765 var k=document.createElement('td');k.className='err-kv-k';k.textContent=m[1].trim();
26766 var v=document.createElement('td');v.className='err-kv-v';v.textContent=m[2];
26767 tr.appendChild(k);tr.appendChild(v);tbl.appendChild(tr);
26768 } else {
26769 tbl=null;
26770 if(line.trim()){var p=document.createElement('p');p.className='err-kv-p';p.textContent=line.trim();frag.appendChild(p);}
26771 }
26772 });
26773 errText.appendChild(frag);
26774 }
26775 }
26776 if(errBox)errBox.classList.add('show');
26777 if(okBox)okBox.classList.remove('show');
26778 }
26779 function clearErr(){
26780 if(errBox)errBox.classList.remove('show');
26781 if(okBox)okBox.classList.remove('show');
26782 }
26783 function validate(){
26784 var val=inp?inp.value.trim():'';
26785 clearErr();
26786 if(!val){if(submitBtn)submitBtn.disabled=true;if(warning)warning.classList.remove('show');return;}
26787 if(submitBtn)submitBtn.disabled=false;
26788 if(warning){
26789 var name=basename(val);
26790 var looksLikeFile=name.toLowerCase().slice(-5)==='.html';
26791 if(expected&&name&&looksLikeFile&&name!==expected)warning.classList.add('show');
26792 else warning.classList.remove('show');
26793 }
26794 }
26795 if(inp){inp.addEventListener('input',validate);inp.addEventListener('keydown',function(e){if(e.key==='Enter')submitBtn&&submitBtn.click();});}
26796 if(browseBtn){
26797 browseBtn.addEventListener('click',function(){
26798 browseBtn.disabled=true;browseBtn.textContent='...';
26799 fetch('/pick-directory')
26800 .then(function(r){return r.ok?r.json():{cancelled:true};})
26801 .then(function(d){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';if(d&&d.selected_path&&inp){inp.value=d.selected_path;validate();}})
26802 .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
26803 });
26804 }
26805 if(submitBtn){
26806 submitBtn.addEventListener('click',function(){
26807 var folder=inp?inp.value.trim():'';
26808 if(!folder){showErr('Please enter or browse to the scan output folder.');return;}
26809 clearErr();
26810 submitBtn.disabled=true;submitBtn.textContent='Restoring\u2026';
26811 var body=new URLSearchParams();
26812 body.set('file_path',folder);
26813 body.set('redirect_url',redirectUrl);
26814 body.set('expected_run_id',runId);
26815 fetch('/locate-report',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
26816 .then(function(r){return r.json().catch(function(){return{ok:false,message:'Server returned an unexpected response (status '+r.status+').'}; });})
26817 .then(function(d){
26818 submitBtn.disabled=false;submitBtn.textContent='Restore Report';
26819 if(d&&d.ok){
26820 if(okBox)okBox.classList.add('show');
26821 setTimeout(function(){window.location.href=d.redirect||redirectUrl;},500);
26822 } else {
26823 showErr(d&&d.message?d.message:'Unknown error. Check that the folder contains the correct scan.');
26824 }
26825 })
26826 .catch(function(e){
26827 submitBtn.disabled=false;submitBtn.textContent='Restore Report';
26828 showErr('Network error: '+String(e));
26829 });
26830 });
26831 }
26832 })();</script>
26833 <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>
26834</body>
26835</html>
26836"##,
26837 ext = "html"
26838)]
26839struct LocateFileTemplate {
26840 run_id: String,
26841 artifact_type: String,
26842 expected_filename: String,
26843 server_mode: bool,
26844 csp_nonce: String,
26845 version: &'static str,
26846}
26847
26848#[derive(Template)]
26851#[template(
26852 source = r##"
26853<!doctype html>
26854<html lang="en">
26855<head>
26856 <meta charset="utf-8">
26857 <meta name="viewport" content="width=device-width, initial-scale=1">
26858 <title>OxideSLOC | Locate Scan Files</title>
26859 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26860 <style nonce="{{ csp_nonce }}">
26861 :root {
26862 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
26863 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
26864 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
26865 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
26866 }
26867 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
26868 *{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;}
26869 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26870 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26871 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
26872 .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);}
26873 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26874 .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));}
26875 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
26876 .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;}
26877 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26878 @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
26879 @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;}}
26880 .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;}
26881 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26882 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
26883 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26884 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26885 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
26886 .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;}
26887 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26888 .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);}
26889 .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;}
26890 .settings-close:hover{color:var(--text);background:var(--surface-2);}
26891 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26892 .settings-modal-body{padding:14px 16px 16px;}
26893 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26894 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26895 .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;}
26896 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26897 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26898 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26899 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26900 .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;}
26901 .tz-select:focus{border-color:var(--oxide);}
26902 .page{max-width:1560px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
26903 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
26904 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
26905 .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
26906 .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;}
26907 .error-box.hidden{display:none;}
26908 .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;}
26909 body.dark-theme .success-box{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
26910 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
26911 .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;}
26912 .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;}
26913 .site-footer a{color:var(--oxide);text-decoration:none;}.site-footer a:hover{text-decoration:underline;}
26914 .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;}
26915 .btn-secondary:hover{background:var(--line);}
26916 .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;}
26917 .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;}
26918 .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;}
26919 @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));}}
26920 .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;}
26921 .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
26922 .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
26923 .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
26924 .relocate-row{display:flex;gap:8px;align-items:stretch;}
26925 .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;}
26926 .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
26927 body.dark-theme .relocate-input{background:var(--surface-2);}
26928 </style>
26929</head>
26930<body>
26931 <div class="background-watermarks" aria-hidden="true">
26932 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26933 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26934 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26935 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26936 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26937 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26938 </div>
26939 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26940 <div class="top-nav">
26941 <div class="top-nav-inner">
26942 <a class="brand" href="/">
26943 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
26944 <div class="brand-copy">
26945 <div class="brand-title">OxideSLOC</div>
26946 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
26947 </div>
26948 </a>
26949 <div class="nav-right">
26950 <a class="nav-pill" href="/">Home</a>
26951 <div class="nav-dropdown">
26952 <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>
26953 <div class="nav-dropdown-menu">
26954 <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>
26955 </div>
26956 </div>
26957 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
26958 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26959 <div class="nav-dropdown">
26960 <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>
26961 <div class="nav-dropdown-menu">
26962 <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>
26963 </div>
26964 </div>
26965 <div class="server-status-wrap" id="server-status-wrap">
26966 <div class="nav-pill server-online-pill" id="server-status-pill">
26967 <span class="status-dot" id="status-dot"></span>
26968 <span id="server-status-label">Server</span>
26969 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
26970 </div>
26971 <div class="server-status-tip">
26972 OxideSLOC is running — accessible on your network.
26973 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
26974 </div>
26975 </div>
26976 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26977 <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>
26978 </button>
26979 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26980 <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>
26981 <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>
26982 </button>
26983 </div>
26984 </div>
26985 </div>
26986
26987 <div class="page">
26988 <div class="panel">
26989 <h1>Scan Files Moved</h1>
26990 <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
26991 <div class="error-box" id="relocate-error-box">{{ message }}</div>
26992 <div class="success-box" id="relocate-success-box">Scan restored — redirecting…</div>
26993 <div class="relocate-section">
26994 <h2>Locate Scan Output</h2>
26995 <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>
26996 <div class="relocate-row">
26997 <input type="text" id="relocate-folder" name="folder_path"
26998 value="{{ folder_hint }}"
26999 placeholder="Path to folder containing scan output..."
27000 class="relocate-input" autocomplete="off" spellcheck="false">
27001 {% if !server_mode %}
27002 <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse…</button>
27003 {% endif %}
27004 </div>
27005 <div style="margin-top:12px;">
27006 <button type="button" id="restore-btn" class="btn-primary" style="border:none;">Restore Scan</button>
27007 </div>
27008 </div>
27009 <div class="actions">
27010 <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
27011 <a class="btn-secondary" href="/view-reports">View Reports</a>
27012 </div>
27013 </div>
27014 </div>
27015 <footer class="site-footer">
27016 oxide-sloc v{{ version }} — local code metrics workbench ·
27017 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
27018 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
27019 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
27020 · <a href="/api-docs" rel="noopener">REST API</a>
27021 </footer>
27022 <script nonce="{{ csp_nonce }}">
27023 (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");});})();
27024 (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);}})();
27025 (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;});})();
27026 </script>
27027 <script nonce="{{ csp_nonce }}">
27028 (function(){
27029 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'}];
27030 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);});}
27031 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
27032 function init(){
27033 var btn=document.getElementById('settings-btn');if(!btn)return;
27034 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
27035 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>';
27036 document.body.appendChild(m);
27037 var g=document.getElementById('scheme-grid');
27038 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);});
27039 var cl=document.getElementById('settings-close');
27040 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);});})();
27041 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');});
27042 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
27043 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
27044 }
27045 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
27046 }());
27047 (function(){
27048 var browseBtn=document.getElementById('browse-relocate-btn');
27049 if(browseBtn){
27050 browseBtn.addEventListener('click',function(){
27051 browseBtn.disabled=true;browseBtn.textContent='...';
27052 var inp=document.getElementById('relocate-folder');
27053 var hint=inp?inp.value:'';
27054 fetch('/pick-directory?kind=reports¤t='+encodeURIComponent(hint))
27055 .then(function(r){return r.ok?r.json():{cancelled:true};})
27056 .then(function(d){
27057 browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';
27058 if(d&&d.selected_path&&inp)inp.value=d.selected_path;
27059 })
27060 .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
27061 });
27062 }
27063 var restoreBtn=document.getElementById('restore-btn');
27064 var errBox=document.getElementById('relocate-error-box');
27065 var okBox=document.getElementById('relocate-success-box');
27066 if(restoreBtn){
27067 restoreBtn.addEventListener('click',function(){
27068 var inp=document.getElementById('relocate-folder');
27069 var folder=inp?inp.value.trim():'';
27070 if(!folder){if(errBox){errBox.textContent='Please enter a folder path.';errBox.classList.remove('hidden');}return;}
27071 restoreBtn.disabled=true;restoreBtn.textContent='Checking\u2026';
27072 var body=new URLSearchParams();
27073 body.set('run_id','{{ run_id }}');
27074 body.set('redirect_url','{{ redirect_url }}');
27075 body.set('folder_path',folder);
27076 fetch('/relocate-scan',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
27077 .then(function(r){return r.json();})
27078 .then(function(d){
27079 restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
27080 if(d&&d.ok){
27081 if(errBox)errBox.classList.add('hidden');
27082 if(okBox){okBox.style.display='block';}
27083 setTimeout(function(){window.location.href=d.redirect||'/compare-scans';},600);
27084 } else {
27085 if(errBox){errBox.textContent=d&&d.message?d.message:'Unknown error.';errBox.classList.remove('hidden');}
27086 }
27087 })
27088 .catch(function(e){
27089 restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
27090 if(errBox){errBox.textContent='Network error: '+String(e);errBox.classList.remove('hidden');}
27091 });
27092 });
27093 }
27094 }());
27095 </script>
27096 <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]';
27097 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;}
27098 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>
27099</body>
27100</html>
27101"##,
27102 ext = "html"
27103)]
27104struct RelocateScanTemplate {
27105 message: String,
27106 run_id: String,
27107 folder_hint: String,
27108 redirect_url: String,
27109 server_mode: bool,
27110 csp_nonce: String,
27111 version: &'static str,
27112}
27113
27114#[derive(Template)]
27117#[template(
27118 source = r##"
27119<!doctype html>
27120<html lang="en">
27121<head>
27122 <meta charset="utf-8">
27123 <meta name="viewport" content="width=device-width, initial-scale=1">
27124 <title>OxideSLOC | View Reports</title>
27125 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
27126 <style nonce="{{ csp_nonce }}">
27127 :root {
27128 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
27129 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
27130 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
27131 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
27132 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
27133 }
27134 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; }
27135 *{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;}
27136 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
27137 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
27138 .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);}
27139 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
27140 .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));}
27141 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
27142 .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;}
27143 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
27144 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
27145 @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; } }
27146 .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;}
27147 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
27148 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
27149 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
27150 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
27151 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
27152 .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;}
27153 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
27154 .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);}
27155 .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;}
27156 .settings-close:hover{color:var(--text);background:var(--surface-2);}
27157 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
27158 .settings-modal-body{padding:14px 16px 16px;}
27159 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
27160 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
27161 .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;}
27162 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
27163 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
27164 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
27165 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
27166 .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;}
27167 .tz-select:focus{border-color:var(--oxide);}
27168 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
27169 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
27170 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
27171 .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
27172 .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
27173 .panel-meta{font-size:13px;color:var(--muted);}
27174 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
27175 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
27176 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
27177 .per-page-label{font-size:13px;color:var(--muted);}
27178 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;}
27179 .filter-input{min-width:180px;cursor:text;}
27180 .table-wrap{width:100%;overflow-x:auto;}
27181 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
27182 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;}
27183 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
27184 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
27185 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
27186 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
27187 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
27188 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
27189 tr:last-child td{border-bottom:none;}
27190 tr:hover td{background:var(--surface-2);}
27191 .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);}
27192 .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);}
27193 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
27194 .metric-num{font-weight:700;color:var(--text);}
27195 .metric-secondary{font-size:11px;color:var(--muted);margin-top:3px;}
27196 .skipped-pill{font-size:10px;font-weight:600;font-style:italic;color:var(--muted);opacity:.9;font-variant-numeric:tabular-nums;white-space:nowrap;}
27197 .git-commit-chip{cursor:help;}
27198 .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;}
27199 .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;}
27200 .btn:hover{background:var(--line);}
27201 .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
27202 .btn.primary:hover{opacity:.9;}
27203 .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;}
27204 .btn-back:hover{background:var(--line);}
27205 .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;}
27206 .export-btn:hover{background:var(--line);}
27207 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
27208 .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
27209 .no-report{color:var(--muted);font-size:11px;font-style:italic;}
27210 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
27211 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
27212 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
27213 .pagination-info{font-size:13px;color:var(--muted);}
27214 .pagination-btns{display:flex;gap:6px;}
27215 .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;}
27216 .pg-btn:hover:not(:disabled){background:var(--line);}
27217 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
27218 .pg-btn:disabled{opacity:.35;cursor:default;}
27219 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
27220 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
27221 .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);}
27222 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
27223 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
27224 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
27225 .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);}
27226 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
27227 .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
27228 .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;}
27229 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
27230 .site-footer a{color:var(--muted);}
27231 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
27232 .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%;}
27233 .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
27234 .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;}
27235 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
27236 .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;}
27237 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
27238 .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;}
27239 .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;}
27240 .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;}
27241 @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));}}
27242 .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;}
27243 .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;}
27244 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
27245 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
27246 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
27247 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
27248 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
27249 .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;}
27250 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
27251 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
27252 .watched-chip-rm:hover{color:var(--oxide);}
27253 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
27254 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
27255 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
27256 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
27257 .rpt-btn{min-width:58px;justify-content:center;}
27258 .flex-row{display:flex;align-items:center;gap:8px;}
27259 .report-cell{overflow:visible;white-space:normal;}
27260 #history-table col:nth-child(1){width:185px;}
27261 #history-table col:nth-child(2){width:220px;}
27262 #history-table col:nth-child(3){width:100px;}
27263 #history-table col:nth-child(4){width:72px;}
27264 #history-table col:nth-child(5){width:82px;}
27265 #history-table col:nth-child(6){width:82px;}
27266 #history-table col:nth-child(7){width:65px;}
27267 #history-table col:nth-child(8){width:90px;}
27268 #history-table col:nth-child(9){width:85px;}
27269 #history-table col:nth-child(10){width:115px;}
27270 #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
27271 .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
27272 .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
27273 .submod-details summary::-webkit-details-marker{display:none;}
27274.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
27275 .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;}
27276 .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
27277 body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
27278 </style>
27279</head>
27280<body>
27281 <div class="background-watermarks" aria-hidden="true">
27282 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27283 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27284 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27285 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27286 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27287 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
27288 </div>
27289 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
27290 <div class="top-nav">
27291 <div class="top-nav-inner">
27292 <a class="brand" href="/">
27293 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
27294 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
27295 </a>
27296 <div class="nav-right">
27297 <a class="nav-pill" href="/">Home</a>
27298 <div class="nav-dropdown">
27299 <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>
27300 <div class="nav-dropdown-menu">
27301 <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>
27302 </div>
27303 </div>
27304 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
27305 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
27306 <div class="nav-dropdown">
27307 <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>
27308 <div class="nav-dropdown-menu">
27309 <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>
27310 </div>
27311 </div>
27312 <div class="server-status-wrap" id="server-status-wrap">
27313 <div class="nav-pill server-online-pill" id="server-status-pill">
27314 <span class="status-dot" id="status-dot"></span>
27315 <span id="server-status-label">Server</span>
27316 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
27317 </div>
27318 <div class="server-status-tip">
27319 OxideSLOC is running — accessible on your network.
27320 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
27321 </div>
27322 </div>
27323 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
27324 <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>
27325 </button>
27326 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
27327 <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>
27328 <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>
27329 </button>
27330 </div>
27331 </div>
27332 </div>
27333
27334 <div class="page">
27335 {% if let Some(err) = browse_error %}
27336 <div class="toast-error">
27337 <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>
27338 {{ err }}
27339 </div>
27340 {% endif %}
27341 {% if linked_count > 0 %}
27342 <div class="toast-success">
27343 <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>
27344 {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
27345 </div>
27346 {% endif %}
27347 <div class="watched-bar">
27348 <div class="watched-bar-left">
27349 <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>
27350 <span class="watched-label">Watched Folders</span>
27351 <div class="watched-chips">
27352 {% if server_mode %}
27353 <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
27354 {% else %}
27355 {% for dir in watched_dirs %}
27356 <span class="watched-chip">
27357 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
27358 <form method="POST" action="/watched-dirs/remove" style="display:contents">
27359 <input type="hidden" name="folder_path" value="{{ dir }}">
27360 <input type="hidden" name="redirect_to" value="/view-reports">
27361 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
27362 </form>
27363 </span>
27364 {% endfor %}
27365 {% if watched_dirs.is_empty() %}
27366 <span class="watched-none">No folders watched — click Choose to add one</span>
27367 {% endif %}
27368 {% endif %}
27369 </div>
27370 </div>
27371 {% if !server_mode %}
27372 <div class="watched-bar-right">
27373 <button type="button" class="btn" id="add-watched-btn">
27374 <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>
27375 Choose
27376 </button>
27377 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
27378 <input type="hidden" name="redirect_to" value="/view-reports">
27379 <button type="submit" class="btn">↻ Refresh</button>
27380 </form>
27381 </div>
27382 {% endif %}
27383 </div>
27384 <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
27385 <div class="scan-overlay-card">
27386 <div class="scan-spinner"></div>
27387 <div class="scan-overlay-text">Scanning folder…</div>
27388 <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
27389 </div>
27390 </div>
27391 <style>
27392 .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);}
27393 .scan-overlay.active{display:flex;}
27394 .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;}
27395 .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;}
27396 @keyframes scanSpin{to{transform:rotate(360deg);}}
27397 .scan-overlay-text{font-size:15px;font-weight:800;color:var(--text);}
27398 .scan-overlay-sub{font-size:12px;color:var(--muted);line-height:1.5;}
27399 </style>
27400 {% if total_scans > 0 %}
27401 <div class="summary-strip">
27402 <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>
27403 <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>
27404 <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>
27405 <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>
27406 </div>
27407 {% endif %}
27408
27409 <section class="panel">
27410 <div class="panel-header">
27411 <div>
27412 <h1>View Reports</h1>
27413 <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
27414 {% 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 %}
27415 </div>
27416 <div class="flex-row">
27417 <button type="button" class="export-btn" id="export-csv-btn">
27418 <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>
27419 Export CSV
27420 </button>
27421 <button type="button" class="export-btn" id="export-xls-btn">
27422 <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>
27423 Export Excel
27424 </button>
27425 </div>
27426 </div>
27427
27428 {% if entries.is_empty() %}
27429 <div class="empty-state">
27430 <strong>No reports with viewable HTML yet</strong>
27431 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.
27432 </div>
27433 {% else %}
27434 <div class="filter-row">
27435 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name…">
27436 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
27437 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
27438 </div>
27439 <div class="table-wrap">
27440 <table id="history-table">
27441 <colgroup>
27442 <col><col><col><col><col><col><col><col><col><col>
27443 </colgroup>
27444 <thead>
27445 <tr id="history-thead">
27446 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27447 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27448 <th>Run ID<div class="col-resize-handle"></div></th>
27449 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27450 <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>
27451 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27452 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27453 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27454 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
27455 <th>Report<div class="col-resize-handle"></div></th>
27456 </tr>
27457 </thead>
27458 <tbody id="history-tbody">
27459 {% for entry in entries %}
27460 <tr class="history-row" data-run="{{ entry.run_id }}"
27461 data-timestamp="{{ entry.timestamp }}"
27462 data-project="{{ entry.project_label }}"
27463 data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
27464 data-skipped="{{ entry.files_skipped }}"
27465 data-comments="{{ entry.comment_lines }}"
27466 data-blank="{{ entry.blank_lines }}"
27467 data-physical="{{ entry.total_physical_lines }}"
27468 data-functions="{{ entry.functions }}"
27469 data-classes="{{ entry.classes }}"
27470 data-variables="{{ entry.variables }}"
27471 data-imports="{{ entry.imports }}"
27472 data-tests="{{ entry.test_count }}"
27473 data-branch="{{ entry.git_branch }}"
27474 data-commit="{{ entry.git_commit }}"
27475 data-has-json="{{ entry.has_json }}"
27476 data-html-url="/runs/html/{{ entry.run_id }}">
27477 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
27478 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
27479 <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
27480 <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>
27481 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
27482 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
27483 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
27484 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">—</span>{% endif %}</td>
27485 <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>
27486 <td class="report-cell">
27487 <div class="actions-cell">
27488 {% 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 %}
27489 {% 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 %}
27490 </div>
27491 {% if !entry.submodule_links.is_empty() %}
27492 <details class="submod-details">
27493 <summary>↳ {{ entry.submodule_links.len() }} submodule(s)</summary>
27494 <div class="submod-link-list">
27495 {% for sub in entry.submodule_links %}
27496 <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
27497 {% endfor %}
27498 </div>
27499 </details>
27500 {% endif %}
27501 </td>
27502 </tr>
27503 {% endfor %}
27504 </tbody>
27505 </table>
27506 </div>
27507 <div class="pagination">
27508 <span class="pagination-info" id="pagination-info"></span>
27509 <div class="pagination-btns" id="pagination-btns"></div>
27510 <div class="flex-row">
27511 <span class="per-page-label">Show</span>
27512 <select class="per-page" id="per-page-sel">
27513 <option value="10">10 per page</option>
27514 <option value="25" selected>25 per page</option>
27515 <option value="50">50 per page</option>
27516 <option value="100">100 per page</option>
27517 </select>
27518 <span class="per-page-label" id="page-range-label"></span>
27519 </div>
27520 </div>
27521 {% endif %}
27522 </section>
27523 </div>
27524
27525 <footer class="site-footer">
27526 local code analysis - metrics, history and reports
27527 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
27528 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
27529 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
27530 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
27531 · <a href="/api-docs" rel="noopener">REST API</a>
27532 </footer>
27533
27534 <script nonce="{{ csp_nonce }}">
27535 (function () {
27536 // ── Theme ──────────────────────────────────────────────────────────────
27537 var storageKey = 'oxide-sloc-theme';
27538 var body = document.body;
27539 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
27540 var toggle = document.getElementById('theme-toggle');
27541 if (toggle) toggle.addEventListener('click', function () {
27542 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
27543 body.classList.toggle('dark-theme', next === 'dark');
27544 try { localStorage.setItem(storageKey, next); } catch(e) {}
27545 });
27546
27547 // ── State ─────────────────────────────────────────────────────────────
27548 var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
27549 var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
27550 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
27551
27552 // Aggregate stats from first (most recent) row
27553 if (allRows.length) {
27554 var first = allRows[0];
27555 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();}
27556 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>':'');}
27557 setChipVal('agg-code', first.dataset.code);
27558 setChipVal('agg-files', first.dataset.files);
27559 var projects = {}; allRows.forEach(function(r){var p=r.dataset.project||'';if(p)projects[p]=true;});
27560 var pe=document.getElementById('agg-projects'); if(pe) pe.textContent=Object.keys(projects).filter(Boolean).length;
27561 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(); });
27562 }
27563
27564 // ── Branch filter population ──────────────────────────────────────────
27565 (function() {
27566 var branches = {};
27567 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
27568 var sel = document.getElementById('branch-filter');
27569 if (sel) Object.keys(branches).sort().forEach(function(b) {
27570 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
27571 });
27572 })();
27573
27574 // ── Filter ────────────────────────────────────────────────────────────
27575 function getFilteredRows() {
27576 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
27577 var branch = ((document.getElementById('branch-filter') || {}).value || '');
27578 return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
27579 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
27580 if (branch && (r.dataset.branch || '') !== branch) return false;
27581 return true;
27582 });
27583 }
27584
27585 // ── Pagination ────────────────────────────────────────────────────────
27586 function renderPage() {
27587 var filtered = getFilteredRows();
27588 var total = filtered.length;
27589 var totalPages = Math.max(1, Math.ceil(total / perPage));
27590 currentPage = Math.min(currentPage, totalPages);
27591 var start = (currentPage - 1) * perPage;
27592 var end = Math.min(start + perPage, total);
27593 var shown = {};
27594 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
27595 Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
27596 r.style.display = shown[r.dataset.run] ? '' : 'none';
27597 });
27598 var rl = document.getElementById('page-range-label');
27599 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
27600 var info = document.getElementById('pagination-info');
27601 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
27602 var btns = document.getElementById('pagination-btns');
27603 if (!btns) return;
27604 btns.innerHTML = '';
27605 function makeBtn(lbl, pg, active, disabled) {
27606 var b = document.createElement('button');
27607 b.className = 'pg-btn' + (active ? ' active' : '');
27608 b.textContent = lbl; b.disabled = disabled;
27609 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
27610 return b;
27611 }
27612 btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
27613 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
27614 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
27615 btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
27616 }
27617
27618 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
27619 window.applyFilters = function() { currentPage = 1; renderPage(); };
27620
27621 // ── Sorting ───────────────────────────────────────────────────────────
27622 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
27623 function doSort(col, type, order) {
27624 var tbody = document.getElementById('history-tbody');
27625 if (!tbody) return;
27626 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
27627 rows.sort(function(a, b) {
27628 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
27629 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
27630 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
27631 return va < vb ? 1 : va > vb ? -1 : 0;
27632 });
27633 rows.forEach(function(r) { tbody.appendChild(r); });
27634 currentPage = 1; renderPage();
27635 }
27636 sortHeaders.forEach(function(th) {
27637 th.addEventListener('click', function(e) {
27638 if (e.target.classList.contains('col-resize-handle')) return;
27639 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
27640 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; 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 th.classList.add('sort-' + sortOrder);
27643 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
27644 doSort(col, type, sortOrder);
27645 });
27646 });
27647
27648 // ── Column resize ─────────────────────────────────────────────────────
27649 (function() {
27650 var table = document.getElementById('history-table');
27651 if (!table) return;
27652 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
27653 var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
27654 ths.forEach(function(th, i) {
27655 var handle = th.querySelector('.col-resize-handle');
27656 if (!handle || !cols[i]) return;
27657 var startX, startW;
27658 handle.addEventListener('mousedown', function(e) {
27659 e.stopPropagation(); e.preventDefault();
27660 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
27661 handle.classList.add('dragging');
27662 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
27663 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
27664 document.addEventListener('mousemove', onMove);
27665 document.addEventListener('mouseup', onUp);
27666 });
27667 });
27668 })();
27669
27670 // ── Full-commit hover tooltip ─────────────────────────────────────────
27671 // The commit chips live inside an overflow:auto table wrapper, which would
27672 // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
27673 // (escaping the scroll container) and follow the cursor. Event delegation
27674 // keeps it working after pagination/sorting re-renders the rows.
27675 (function() {
27676 var tip = document.createElement('div');
27677 tip.className = 'commit-tip';
27678 tip.setAttribute('role', 'tooltip');
27679 document.body.appendChild(tip);
27680 var shown = false;
27681 function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
27682 function place(e) {
27683 var pad = 14, r = tip.getBoundingClientRect();
27684 var x = e.clientX + pad, y = e.clientY + pad;
27685 if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
27686 if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
27687 tip.style.left = x + 'px'; tip.style.top = y + 'px';
27688 }
27689 function hide() { tip.style.display = 'none'; shown = false; }
27690 document.addEventListener('mouseover', function(e) {
27691 var chip = chipFrom(e.target);
27692 if (!chip) return;
27693 var full = chip.getAttribute('data-full-commit');
27694 if (!full) return;
27695 tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
27696 });
27697 document.addEventListener('mousemove', function(e) {
27698 if (!shown) return;
27699 if (chipFrom(e.target)) place(e); else hide();
27700 });
27701 document.addEventListener('mouseout', function(e) {
27702 if (chipFrom(e.target)) hide();
27703 });
27704 })();
27705
27706 // ── Reset view ────────────────────────────────────────────────────────
27707 window.resetView = function() {
27708 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
27709 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
27710 sortCol = null; sortOrder = 'asc';
27711 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
27712 var tbody = document.getElementById('history-tbody');
27713 if (tbody) {
27714 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
27715 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
27716 rows.forEach(function(r) { tbody.appendChild(r); });
27717 }
27718 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
27719 var table = document.getElementById('history-table');
27720 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
27721 currentPage = 1; renderPage();
27722 };
27723
27724 renderPage();
27725
27726 // ── Export helpers ────────────────────────────────────────────────────
27727 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
27728 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
27729 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);}
27730 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;');}
27731 function slocXlsx(fname,sheet,hdrs,rows){
27732 var enc=new TextEncoder();
27733 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;}
27734 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;}
27735 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
27736 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
27737 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
27738 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;}
27739 function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
27740 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];}
27741 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
27742 // Style 0=normal, 1=header(orange fill/white bold), 2=number(#,##0 right-aligned), 3=text(@)
27743 var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
27744 +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
27745 +'<fonts count="2">'
27746 +'<font><sz val="11"/><name val="Calibri"/></font>'
27747 +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
27748 +'</fonts>'
27749 +'<fills count="3">'
27750 +'<fill><patternFill patternType="none"/></fill>'
27751 +'<fill><patternFill patternType="gray125"/></fill>'
27752 +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
27753 +'</fills>'
27754 +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
27755 +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
27756 +'<cellXfs count="4">'
27757 +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
27758 +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27759 +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
27760 +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
27761 +'</cellXfs>'
27762 +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
27763 +'</styleSheet>';
27764 var rx='<row r="1">';
27765 hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
27766 rx+='</row>';
27767 rows.forEach(function(row,ri){
27768 var rn=ri+2;rx+='<row r="'+rn+'">';
27769 row.forEach(function(cell,c){
27770 var ref=colRef(c,rn),sv=String(cell==null?'':cell);
27771 var isNum=sv!==''&&!isNaN(Number(sv))&&isFinite(Number(sv))&&/^[+\-]?\d/.test(sv);
27772 var isPct=!isNum&&/^\d+\.?\d*%$/.test(sv);
27773 if(isNum){rx+='<c r="'+ref+'" s="2"><v>'+xe(sv)+'</v></c>';}
27774 else if(isPct){rx+='<c r="'+ref+'" t="s" s="3"><v>'+S(sv)+'</v></c>';}
27775 else{rx+='<c r="'+ref+'" t="s"><v>'+S(sv)+'</v></c>';}
27776 });
27777 rx+='</row>';
27778 });
27779 var lastCol=hdrs.length,lastRow=rows.length+1;
27780 var tableRef='A1:'+colNm(lastCol)+lastRow;
27781 var tableXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27782 +'<table xmlns="'+sns+'" id="1" name="ScanHistory" displayName="ScanHistory" ref="'+tableRef+'" totalsRowShown="0">'
27783 +'<autoFilter ref="'+tableRef+'"/>'
27784 +'<tableColumns count="'+lastCol+'">'
27785 +hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
27786 +'</tableColumns>'
27787 +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
27788 +'</table>';
27789 var wsRels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27790 +'<Relationships xmlns="'+pns+'relationships">'
27791 +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table1.xml"/>'
27792 +'</Relationships>';
27793 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>';
27794 var sh='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
27795 +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
27796 +'<sheetFormatPr defaultRowHeight="15"/><sheetData>'+rx+'</sheetData>'
27797 +'<tableParts count="1"><tablePart r:id="rId1"/></tableParts>'
27798 +'</worksheet>';
27799 var F={
27800 '[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>',
27801 '_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>',
27802 '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>',
27803 '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>',
27804 'xl/styles.xml':stl,
27805 'xl/sharedStrings.xml':ssXml,
27806 'xl/worksheets/sheet1.xml':sh,
27807 'xl/worksheets/_rels/sheet1.xml.rels':wsRels,
27808 'xl/tables/table1.xml':tableXml
27809 };
27810 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'];
27811 var zparts=[],zcds=[],zoff=0,znf=0;
27812 order.forEach(function(name){
27813 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
27814 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]);
27815 var entry=new Uint8Array(lha.length+nb.length+sz);
27816 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
27817 zparts.push(entry);
27818 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));
27819 var cde=new Uint8Array(cda.length+nb.length);
27820 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
27821 zcds.push(cde);zoff+=entry.length;znf++;
27822 });
27823 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
27824 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]);
27825 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
27826 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
27827 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
27828 zout.set(new Uint8Array(ea),zpos);
27829 slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
27830 }
27831
27832 // Multi-sheet XLSX builder for the scan-history export.
27833 // Styles: 0=normal 1=col-header(orange/white bold) 2=number(right) 3=section 4=bold-label 5=number(left) 6=text(@)
27834 function slocXlsxMulti(fname,sheets){
27835 var enc=new TextEncoder();
27836 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;}
27837 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;}
27838 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
27839 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
27840 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
27841 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];}
27842 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;}
27843 function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
27844 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
27845 var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
27846 +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
27847 +'<fonts count="3">'
27848 +'<font><sz val="11"/><name val="Calibri"/></font>'
27849 +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
27850 +'<font><sz val="11"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font>'
27851 +'</fonts>'
27852 +'<fills count="4">'
27853 +'<fill><patternFill patternType="none"/></fill>'
27854 +'<fill><patternFill patternType="gray125"/></fill>'
27855 +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
27856 +'<fill><patternFill patternType="solid"><fgColor rgb="FFFAF0E6"/><bgColor indexed="64"/></patternFill></fill>'
27857 +'</fills>'
27858 +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
27859 +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
27860 +'<cellXfs count="7">'
27861 +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
27862 +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27863 +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
27864 +'<xf numFmtId="0" fontId="2" fillId="3" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
27865 +'<xf numFmtId="0" fontId="2" fillId="0" borderId="0" xfId="0" applyFont="1"/>'
27866 +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="left"/></xf>'
27867 +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
27868 +'</cellXfs>'
27869 +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
27870 +'</styleSheet>';
27871 var wsXmls=[],tableCounter=0,tableXmls={},wsRelsXmls={};
27872 sheets.forEach(function(sh,sheetIdx){
27873 var rx='<row r="1">';
27874 sh.hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
27875 rx+='</row>';
27876 var rn=2;
27877 sh.rows.forEach(function(row){
27878 if(!row||row.length===0){rx+='<row r="'+rn+'"/>';rn++;return;}
27879 if(row.length===1&&row[0]&&typeof row[0]==='object'&&row[0]._sec){
27880 rx+='<row r="'+rn+'">';
27881 rx+='<c r="'+colRef(0,rn)+'" t="s" s="3"><v>'+S(row[0].v)+'</v></c>';
27882 for(var ec=1;ec<sh.hdrs.length;ec++){rx+='<c r="'+colRef(ec,rn)+'" s="3"/>';}
27883 rx+='</row>';rn++;return;
27884 }
27885 rx+='<row r="'+rn+'">';
27886 row.forEach(function(cell,c){
27887 var ref=colRef(c,rn);
27888 if(cell===null||cell===undefined||cell===''){rx+='<c r="'+ref+'"/>';return;}
27889 if(typeof cell==='object'&&cell!==null){
27890 var cv=cell.v,cs=cell.s!=null?cell.s:0;
27891 if(typeof cv==='number'){rx+='<c r="'+ref+'" s="'+cs+'"><v>'+xe(cv)+'</v></c>';}
27892 else{rx+='<c r="'+ref+'" t="s" s="'+cs+'"><v>'+S(cv)+'</v></c>';}
27893 return;
27894 }
27895 if(typeof cell==='number'){rx+='<c r="'+ref+'" s="2"><v>'+xe(cell)+'</v></c>';return;}
27896 rx+='<c r="'+ref+'" t="s"><v>'+S(cell)+'</v></c>';
27897 });
27898 rx+='</row>';rn++;
27899 });
27900 var cw='';
27901 if(sh.colWidths&&sh.colWidths.length>0){
27902 cw='<cols>';
27903 sh.colWidths.forEach(function(w,i){cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';});
27904 cw+='</cols>';
27905 }
27906 var tblParts='';
27907 if(!sh.isKv&&sh.hdrs.length>0&&sh.rows.length>0){
27908 tableCounter++;
27909 var tc=tableCounter,colCount=sh.hdrs.length,rowCount=sh.rows.length+1;
27910 var tRef='A1:'+colNm(colCount)+rowCount;
27911 tableXmls['xl/tables/table'+tc+'.xml']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27912 +'<table xmlns="'+sns+'" id="'+tc+'" name="Table'+tc+'" displayName="Table'+tc+'" ref="'+tRef+'" totalsRowShown="0">'
27913 +'<autoFilter ref="'+tRef+'"/>'
27914 +'<tableColumns count="'+colCount+'">'
27915 +sh.hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
27916 +'</tableColumns>'
27917 +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
27918 +'</table>';
27919 wsRelsXmls['xl/worksheets/_rels/sheet'+(sheetIdx+1)+'.xml.rels']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
27920 +'<Relationships xmlns="'+pns+'relationships">'
27921 +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table'+tc+'.xml"/>'
27922 +'</Relationships>';
27923 tblParts='<tableParts count="1"><tablePart r:id="rId1"/></tableParts>';
27924 }
27925 wsXmls.push('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
27926 +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
27927 +'<sheetFormatPr defaultRowHeight="15"/>'+cw+'<sheetData>'+rx+'</sheetData>'+tblParts+'</worksheet>');
27928 });
27929 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>';
27930 var ctOver=sheets.map(function(_,i){return'<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}).join('');
27931 var ctTable=Object.keys(tableXmls).map(function(k){return'<Override PartName="/'+k+'" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';}).join('');
27932 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>';
27933 var wbSh=sheets.map(function(sh,i){return'<sheet name="'+xe(sh.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}).join('');
27934 var wbXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="'+sns+'" xmlns:r="'+ons+'relationships"><sheets>'+wbSh+'</sheets></workbook>';
27935 var wbR=sheets.map(function(_,i){return'<Relationship Id="rId'+(i+1)+'" Type="'+ons+'relationships/worksheet" Target="worksheets/sheet'+(i+1)+'.xml"/>';}).join('');
27936 wbR+='<Relationship Id="rId'+(sheets.length+1)+'" Type="'+ons+'relationships/styles" Target="styles.xml"/>'
27937 +'<Relationship Id="rId'+(sheets.length+2)+'" Type="'+ons+'relationships/sharedStrings" Target="sharedStrings.xml"/>';
27938 var wbRXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships">'+wbR+'</Relationships>';
27939 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};
27940 var order=['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml'];
27941 sheets.forEach(function(_,i){var k='xl/worksheets/sheet'+(i+1)+'.xml';F[k]=wsXmls[i];order.push(k);});
27942 Object.keys(wsRelsXmls).forEach(function(k){F[k]=wsRelsXmls[k];order.push(k);});
27943 Object.keys(tableXmls).forEach(function(k){F[k]=tableXmls[k];order.push(k);});
27944 var zparts=[],zcds=[],zoff=0,znf=0;
27945 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++;});
27946 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
27947 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]);
27948 var tot=zoff+cdSz+ea.length,zout=new Uint8Array(tot),zpos=0;
27949 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
27950 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
27951 zout.set(new Uint8Array(ea),zpos);
27952 slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
27953 }
27954
27955 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'};
27956 function langName(k){return LANG_NAMES[k]||String(k||'').replace(/_/g,' ')||'(unknown)';}
27957
27958 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'];
27959 function getHistoryRows(){
27960 var r=[];
27961 document.querySelectorAll('#history-tbody .history-row').forEach(function(tr){
27962 var code=Number(tr.getAttribute('data-code'))||0;
27963 var phys=Number(tr.getAttribute('data-physical'))||0;
27964 var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
27965 r.push([
27966 tr.getAttribute('data-timestamp')||'',
27967 tr.getAttribute('data-project')||'',
27968 tr.getAttribute('data-run')||'',
27969 tr.getAttribute('data-physical')||'',
27970 tr.getAttribute('data-code')||'',
27971 tr.getAttribute('data-comments')||'',
27972 tr.getAttribute('data-blank')||'',
27973 tr.getAttribute('data-files')||'',
27974 tr.getAttribute('data-skipped')||'',
27975 tr.getAttribute('data-functions')||'',
27976 tr.getAttribute('data-classes')||'',
27977 tr.getAttribute('data-variables')||'',
27978 tr.getAttribute('data-imports')||'',
27979 tr.getAttribute('data-tests')||'',
27980 dens,
27981 tr.getAttribute('data-branch')||'',
27982 tr.getAttribute('data-commit')||''
27983 ]);
27984 });
27985 return r;
27986 }
27987 window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
27988 window.exportHistoryXls = function(){
27989 var histRows=getHistoryRows();
27990 function toN(v){var n=Number(v);return isNaN(n)||v===''?0:n;}
27991 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]];});
27992 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]};
27993 var jsonRow=document.querySelector('#history-tbody .history-row[data-has-json="true"]');
27994 if(!jsonRow){slocXlsxMulti('scan-history.xlsx',[histSheet]);return;}
27995 var runId=jsonRow.getAttribute('data-run')||'';
27996 var proj=(jsonRow.getAttribute('data-project')||'Latest').substring(0,18);
27997 function sn(suffix){var p=proj.substring(0,Math.max(1,28-suffix.length));return p+' - '+suffix;}
27998 fetch('/runs/json/'+runId)
27999 .then(function(r){if(!r.ok)throw new Error('no json');return r.json();})
28000 .then(function(run){
28001 var tot=run.summary_totals||{};
28002 var phys=Number(tot.total_physical_lines)||0,code=Number(tot.code_lines)||0;
28003 var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
28004 function B(v){return{v:v,s:4};}
28005 function N(v){return{v:typeof v==='number'?v:Number(v),s:5};}
28006 var sumRows=[
28007 [{_sec:true,v:'RUN INFORMATION'}],
28008 [B('Run ID'),(run.tool&&run.tool.run_id)||''],
28009 [B('Timestamp'),(run.tool&&run.tool.timestamp_utc)||''],
28010 [B('Project'),(run.effective_configuration&&run.effective_configuration.reporting&&run.effective_configuration.reporting.report_title)||proj],
28011 [B('Branch'),run.git_branch||''],
28012 [B('Commit'),run.git_commit_long||run.git_commit_short||''],
28013 [B('OS'),(run.environment&&(run.environment.operating_system+' / '+run.environment.architecture))||''],
28014 [B('Files Analyzed'),N(tot.files_analyzed)],
28015 [B('Files Skipped'),N(tot.files_skipped)],
28016 [],
28017 [{_sec:true,v:'CODE METRICS'}],
28018 [B('Physical Lines'),N(phys)],
28019 [B('Code Lines'),N(code)],
28020 [B('Comments'),N(tot.comment_lines)],
28021 [B('Blank Lines'),N(tot.blank_lines)],
28022 [B('Mixed Separate'),N(tot.mixed_lines_separate)],
28023 [B('Functions'),N(tot.functions)],
28024 [B('Classes / Types'),N(tot.classes)],
28025 [B('Variables'),N(tot.variables)],
28026 [B('Imports'),N(tot.imports)],
28027 [B('Tests'),N(tot.test_count)],
28028 [B('Assertions'),N(tot.test_assertion_count)],
28029 [B('Test Suites'),N(tot.test_suite_count)],
28030 [B('Code Density'),{v:dens,s:6}],
28031 [B('Tool Version'),'oxide-sloc '+((run.tool&&run.tool.version)||'')],
28032 ];
28033 var langHdrs=['Language','Files','Physical Lines','Code Lines','Code Density','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Test Suites'];
28034 var langRows=(run.totals_by_language||[]).map(function(l){
28035 var lp=Number(l.total_physical_lines)||0,lc=Number(l.code_lines)||0;
28036 var ld=lp>0?(lc/lp*100).toFixed(1)+'%':'0%';
28037 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];
28038 });
28039 var pfHdrs=['File','Language','Physical Lines','Code Lines','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Size (bytes)'];
28040 var pfRows=(run.per_file_records||[]).map(function(r){
28041 var rc=r.raw_line_categories||{},ec=r.effective_counts||{};
28042 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];
28043 });
28044 var skHdrs=['File','Status','Size (bytes)'];
28045 var skRows=(run.skipped_file_records||[]).map(function(r){
28046 return [r.relative_path,String(r.status||'').replace(/_/g,' '),r.size_bytes||0];
28047 });
28048 slocXlsxMulti('scan-history.xlsx',[
28049 histSheet,
28050 {name:sn('Summary'),hdrs:['Field / Metric','Value'],rows:sumRows,colWidths:[22,44],isKv:true},
28051 {name:sn('Languages'),hdrs:langHdrs,rows:langRows,colWidths:[16,7,14,12,13,12,10,11,10,10,10,8,11,12]},
28052 {name:sn('Per-File'),hdrs:pfHdrs,rows:pfRows,colWidths:[48,12,14,12,12,10,11,10,10,10,8,11,12]},
28053 {name:sn('Skipped'),hdrs:skHdrs,rows:skRows,colWidths:[52,24,12]}
28054 ]);
28055 })
28056 .catch(function(){slocXlsxMulti('scan-history.xlsx',[histSheet]);});
28057 };
28058
28059 var csvBtn = document.getElementById('export-csv-btn');
28060 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
28061 var xlsBtn = document.getElementById('export-xls-btn');
28062 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
28063
28064 // ── Remaining CSP-safe event bindings ────────────────────────────────
28065 (function wireEvents() {
28066 var el;
28067 el = document.getElementById('reset-view-btn');
28068 if (el) el.addEventListener('click', window.resetView);
28069 el = document.getElementById('project-filter');
28070 if (el) el.addEventListener('input', window.applyFilters);
28071 el = document.getElementById('branch-filter');
28072 if (el) el.addEventListener('change', window.applyFilters);
28073 el = document.getElementById('per-page-sel');
28074 if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
28075 (function(){
28076 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');};
28077 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);
28078 })();
28079 el = document.getElementById('add-watched-btn');
28080 if (el) el.addEventListener('click', function() {
28081 fetch('/pick-directory?kind=reports')
28082 .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
28083 .then(function(data) {
28084 if (!data.cancelled && data.selected_path) {
28085 var form = document.createElement('form');
28086 form.method = 'POST';
28087 form.action = '/watched-dirs/add';
28088 var ri = document.createElement('input');
28089 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
28090 var fi = document.createElement('input');
28091 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
28092 form.appendChild(ri); form.appendChild(fi);
28093 document.body.appendChild(form);
28094 if (window.__scanOverlay) window.__scanOverlay();
28095 form.submit();
28096 }
28097 })
28098 .catch(function(e) { alert('Could not open folder picker: ' + e); });
28099 });
28100 })();
28101
28102 (function randomizeWatermarks() {
28103 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
28104 if (!wms.length) return;
28105 var placed = [];
28106 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;}
28107 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];}
28108 var half=Math.floor(wms.length/2);
28109 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;});
28110 })();
28111
28112 (function spawnCodeParticles() {
28113 var container = document.getElementById('code-particles');
28114 if (!container) return;
28115 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'];
28116 for (var i = 0; i < 38; i++) {
28117 (function(idx) {
28118 var el = document.createElement('span');
28119 el.className = 'code-particle';
28120 el.textContent = snippets[idx % snippets.length];
28121 var left = Math.random() * 94 + 2;
28122 var top = Math.random() * 88 + 6;
28123 var dur = (Math.random() * 10 + 9).toFixed(1);
28124 var delay = (Math.random() * 18).toFixed(1);
28125 var rot = (Math.random() * 26 - 13).toFixed(1);
28126 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
28127 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';
28128 container.appendChild(el);
28129 })(i);
28130 }
28131 })();
28132 })();
28133 </script>
28134 <script nonce="{{ csp_nonce }}">
28135 (function(){
28136 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'}];
28137 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);});}
28138 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
28139 function init(){
28140 var btn=document.getElementById('settings-btn');if(!btn)return;
28141 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
28142 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>';
28143 document.body.appendChild(m);
28144 var g=document.getElementById('scheme-grid');
28145 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);});
28146 var cl=document.getElementById('settings-close');
28147 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);});})();
28148 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');});
28149 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
28150 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
28151 }
28152 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
28153 }());
28154 </script>
28155 <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>
28156</body>
28157</html>
28158"##,
28159 ext = "html"
28160)]
28161struct HistoryTemplate {
28162 version: &'static str,
28163 entries: Vec<HistoryEntryRow>,
28164 total_scans: usize,
28165 linked_count: usize,
28166 browse_error: Option<String>,
28167 watched_dirs: Vec<String>,
28168 csp_nonce: String,
28169 server_mode: bool,
28170}
28171
28172#[derive(Template)]
28175#[template(
28176 source = r##"
28177<!doctype html>
28178<html lang="en">
28179<head>
28180 <meta charset="utf-8">
28181 <meta name="viewport" content="width=device-width, initial-scale=1">
28182 <title>OxideSLOC | Compare Scans</title>
28183 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
28184 <style nonce="{{ csp_nonce }}">
28185 :root {
28186 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
28187 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
28188 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
28189 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
28190 --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
28191 }
28192 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
28193 *{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;}
28194 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
28195 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
28196 .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);}
28197 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
28198 .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));}
28199 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
28200 .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;}
28201 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
28202 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
28203 @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; } }
28204 .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;}
28205 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
28206 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
28207 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
28208 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
28209 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
28210 .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;}
28211 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
28212 .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);}
28213 .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;}
28214 .settings-close:hover{color:var(--text);background:var(--surface-2);}
28215 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
28216 .settings-modal-body{padding:14px 16px 16px;}
28217 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
28218 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
28219 .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;}
28220 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
28221 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
28222 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
28223 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
28224 .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;}
28225 .tz-select:focus{border-color:var(--oxide);}
28226 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
28227 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
28228 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
28229 .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
28230 .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
28231 .panel-meta{font-size:13px;color:var(--muted);margin:0;}
28232 .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
28233 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
28234 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
28235 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
28236 .per-page-label{font-size:13px;color:var(--muted);}
28237 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;}
28238 .filter-input{min-width:180px;cursor:text;}
28239 .table-wrap{width:100%;overflow-x:auto;}
28240 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
28241 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;}
28242 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
28243 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
28244 #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;}
28245 #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
28246 #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
28247 #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
28248 #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
28249 #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
28250 #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
28251 #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
28252 #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
28253 #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
28254 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
28255 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
28256 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
28257 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
28258 tr:last-child td{border-bottom:none;}
28259 tr.selected td{background:var(--sel-bg);}
28260 tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
28261 tr:hover:not(.selected):not(.row-locked) td{background:var(--surface-2);}
28262 tr{cursor:pointer;}
28263 tr.row-locked{opacity:.35;cursor:not-allowed;}
28264 tr.row-locked td{pointer-events:none;}
28265 .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;}
28266 .compare-all-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);flex-shrink:0;}
28267 .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;}
28268 .compare-all-btn:hover{background:rgba(111,155,255,0.18);}
28269 body.dark-theme .compare-all-btn{background:rgba(111,155,255,0.12);color:var(--accent);border-color:var(--accent);}
28270 body.dark-theme .compare-all-btn:hover{background:rgba(111,155,255,0.22);}
28271 .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);}
28272 .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);}
28273 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
28274 .metric-num{font-weight:700;color:var(--text);}
28275 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
28276 .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;}
28277 .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;}
28278 tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
28279 .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;}
28280 .btn:hover{background:var(--line);}
28281 .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
28282 .btn.primary:hover{opacity:.9;}
28283 .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
28284 .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;}
28285 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
28286 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
28287 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
28288 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
28289 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
28290 .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;}
28291 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
28292 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
28293 .watched-chip-rm:hover{color:var(--oxide);}
28294 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
28295 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
28296 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
28297 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
28298 .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
28299 .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;}
28300 .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;}
28301 .btn-back:hover{background:var(--line);}
28302 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
28303 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
28304 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
28305 .pagination-info{font-size:13px;color:var(--muted);}
28306 .pagination-btns{display:flex;gap:6px;}
28307 .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;}
28308 .pg-btn:hover:not(:disabled){background:var(--line);}
28309 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
28310 .pg-btn:disabled{opacity:.35;cursor:default;}
28311 .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
28312 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
28313 .site-footer a{color:var(--muted);}
28314 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
28315 .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;}
28316 .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;}
28317 .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;}
28318 @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));}}
28319 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
28320 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
28321 .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);}
28322 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
28323 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
28324 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
28325 .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);}
28326 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
28327 .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
28328 .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;}
28329 .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;}
28330 .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%;}
28331 body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
28332 .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;}
28333 body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
28334 #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
28335 .hidden{display:none!important;}
28336 .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%;}
28337 @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
28338 body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
28339 .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;}
28340 .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
28341 .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
28342 .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;}
28343 .scope-option:hover{background:var(--line);}
28344 .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
28345 body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
28346 .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;}
28347 .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
28348 .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
28349 .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
28350 .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;}
28351 </style>
28352</head>
28353<body>
28354 <div class="background-watermarks" aria-hidden="true">
28355 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28356 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28357 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28358 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28359 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28360 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
28361 </div>
28362 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
28363 <div class="top-nav">
28364 <div class="top-nav-inner">
28365 <a class="brand" href="/">
28366 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
28367 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
28368 </a>
28369 <div class="nav-right">
28370 <a class="nav-pill" href="/">Home</a>
28371 <div class="nav-dropdown">
28372 <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>
28373 <div class="nav-dropdown-menu">
28374 <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>
28375 </div>
28376 </div>
28377 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
28378 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
28379 <div class="nav-dropdown">
28380 <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>
28381 <div class="nav-dropdown-menu">
28382 <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>
28383 </div>
28384 </div>
28385 <div class="server-status-wrap" id="server-status-wrap">
28386 <div class="nav-pill server-online-pill" id="server-status-pill">
28387 <span class="status-dot" id="status-dot"></span>
28388 <span id="server-status-label">Server</span>
28389 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
28390 </div>
28391 <div class="server-status-tip">
28392 OxideSLOC is running — accessible on your network.
28393 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
28394 </div>
28395 </div>
28396 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
28397 <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>
28398 </button>
28399 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
28400 <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>
28401 <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>
28402 </button>
28403 </div>
28404 </div>
28405 </div>
28406
28407 <div class="page">
28408 <div class="watched-bar">
28409 <div class="watched-bar-left">
28410 <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>
28411 <span class="watched-label">Watched Folders</span>
28412 <div class="watched-chips">
28413 {% if server_mode %}
28414 <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
28415 {% else %}
28416 {% for dir in watched_dirs %}
28417 <span class="watched-chip">
28418 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
28419 <form method="POST" action="/watched-dirs/remove" style="display:contents">
28420 <input type="hidden" name="folder_path" value="{{ dir }}">
28421 <input type="hidden" name="redirect_to" value="/compare-scans">
28422 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
28423 </form>
28424 </span>
28425 {% endfor %}
28426 {% if watched_dirs.is_empty() %}
28427 <span class="watched-none">No folders watched — click Choose to add one</span>
28428 {% endif %}
28429 {% endif %}
28430 </div>
28431 </div>
28432 {% if !server_mode %}
28433 <div class="watched-bar-right">
28434 <button type="button" class="btn" id="add-watched-btn">
28435 <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>
28436 Choose
28437 </button>
28438 <form method="POST" action="/watched-dirs/refresh" style="display:contents">
28439 <input type="hidden" name="redirect_to" value="/compare-scans">
28440 <button type="submit" class="btn">↻ Refresh</button>
28441 </form>
28442 </div>
28443 {% endif %}
28444 </div>
28445 <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
28446 <div class="scan-overlay-card">
28447 <div class="scan-spinner"></div>
28448 <div class="scan-overlay-text">Scanning folder…</div>
28449 <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
28450 </div>
28451 </div>
28452 <style>
28453 .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);}
28454 .scan-overlay.active{display:flex;}
28455 .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;}
28456 .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;}
28457 @keyframes scanSpin{to{transform:rotate(360deg);}}
28458 .scan-overlay-text{font-size:15px;font-weight:800;color:var(--text);}
28459 .scan-overlay-sub{font-size:12px;color:var(--muted);line-height:1.5;}
28460 </style>
28461 {% if total_scans > 0 %}
28462 <div class="summary-strip">
28463 <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>
28464 <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>
28465 <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>
28466 <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>
28467 </div>
28468 {% endif %}
28469 <section class="panel">
28470 <div class="panel-header">
28471 <div>
28472 <h1>Compare Scans</h1>
28473 <p class="panel-meta">{{ total_scans }} scan record(s) available. Select two or more scans from the same project, then press Compare.</p>
28474 </div>
28475 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
28476 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;">
28477 <button class="btn primary" id="compare-btn" disabled>
28478 <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>
28479 Compare <span class="sel-count" id="sel-count">0</span> Selected
28480 </button>
28481 </div>
28482 </div>
28483 </div>
28484
28485 {% if entries.is_empty() %}
28486 <div class="empty-state">
28487 <strong>No scans yet</strong>
28488 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.
28489 </div>
28490 {% else %}
28491 <div class="filter-row">
28492 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name…">
28493 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
28494 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
28495 </div>
28496 <div class="scope-panel hidden" id="scope-panel">
28497 <div class="scope-panel-label">
28498 <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>
28499 Compare scope — choose what to include
28500 </div>
28501 <div class="scope-options" id="scope-options"></div>
28502 </div>
28503 {% if total_scans > 0 %}
28504 <div class="hint-right-wrap" style="display:flex;justify-content:flex-end;margin:6px 0 8px;">
28505 <div class="instruction-bar" style="margin:0;max-width:fit-content;flex-shrink:0;">
28506 <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>
28507 Select rows from the <strong>same project</strong>, then press <strong>Compare</strong> — or use <strong>Compare All</strong> for a full project history.
28508 </div>
28509 </div>
28510 {% endif %}
28511 <div id="compare-all-bar" class="compare-all-bar" style="display:none">
28512 <span class="compare-all-label">
28513 <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>
28514 Quick Compare All
28515 </span>
28516 </div>
28517 <div class="table-wrap">
28518 <table id="compare-table">
28519 <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
28520 <thead>
28521 <tr id="compare-thead">
28522 <th><div class="col-resize-handle"></div></th>
28523 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28524 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28525 <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
28526 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28527 <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>
28528 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28529 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28530 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28531 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
28532 <th>Submodules<div class="col-resize-handle"></div></th>
28533 </tr>
28534 </thead>
28535 <tbody id="compare-tbody">
28536 {% for entry in entries %}
28537 <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
28538 data-timestamp="{{ entry.timestamp }}" data-sort-ts="{{ entry.timestamp_utc_ms }}"
28539 data-project="{{ entry.project_label }}"
28540 data-files="{{ entry.files_analyzed }}"
28541 data-code="{{ entry.code_lines }}"
28542 data-comments="{{ entry.comment_lines }}"
28543 data-blank="{{ entry.blank_lines }}"
28544 data-branch="{{ entry.git_branch }}"
28545 data-commit="{{ entry.git_commit }}"
28546 data-submodules="{{ entry.submodule_names_csv }}">
28547 <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
28548 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
28549 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
28550 <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
28551 <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
28552 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
28553 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
28554 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
28555 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span style="color:var(--muted)">—</span>{% endif %}</td>
28556 <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>
28557 <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>
28558 </tr>
28559 {% endfor %}
28560 </tbody>
28561 </table>
28562 </div>
28563 <div class="pagination">
28564 <span class="pagination-info" id="pagination-info"></span>
28565 <div class="pagination-btns" id="pagination-btns"></div>
28566 <div class="flex-row">
28567 <span class="per-page-label">Show</span>
28568 <select class="per-page" id="per-page-sel">
28569 <option value="10">10 per page</option>
28570 <option value="25" selected>25 per page</option>
28571 <option value="50">50 per page</option>
28572 <option value="100">100 per page</option>
28573 </select>
28574 <span class="per-page-label" id="page-range-label"></span>
28575 </div>
28576 </div>
28577 {% endif %}
28578 </section>
28579 </div>
28580
28581 <footer class="site-footer">
28582 local code analysis - metrics, history and reports
28583 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
28584 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
28585 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
28586 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
28587 · <a href="/api-docs" rel="noopener">REST API</a>
28588 </footer>
28589
28590 <script nonce="{{ csp_nonce }}">
28591 (function () {
28592 // ── Theme ──────────────────────────────────────────────────────────────
28593 var storageKey = 'oxide-sloc-theme';
28594 var body = document.body;
28595 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
28596 var toggle = document.getElementById('theme-toggle');
28597 if (toggle) toggle.addEventListener('click', function () {
28598 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
28599 body.classList.toggle('dark-theme', next === 'dark');
28600 try { localStorage.setItem(storageKey, next); } catch(e) {}
28601 });
28602
28603 // ── State ─────────────────────────────────────────────────────────────
28604 var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
28605 var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
28606 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
28607 window._allCompareRows = allRows;
28608
28609 // ── Stat chips ────────────────────────────────────────────────────────
28610 (function() {
28611 var projects = {}, latestTs = '', latestRow = null;
28612 allRows.forEach(function(r) {
28613 var p = r.dataset.project || ''; if (p) projects[p] = true;
28614 var ts = r.dataset.timestamp || '';
28615 if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
28616 });
28617 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();}
28618 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>':'');}
28619 var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
28620 if (latestRow) {
28621 setChipVal('agg-code', latestRow.dataset.code);
28622 setChipVal('agg-files', latestRow.dataset.files);
28623 }
28624 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(); });
28625 })();
28626
28627 // ── Branch filter population ──────────────────────────────────────────
28628 (function() {
28629 var branches = {};
28630 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
28631 var sel = document.getElementById('branch-filter');
28632 if (sel) Object.keys(branches).sort().forEach(function(b) {
28633 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
28634 });
28635 })();
28636
28637 // ── Filter ────────────────────────────────────────────────────────────
28638 function getFilteredRows() {
28639 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
28640 var branch = ((document.getElementById('branch-filter') || {}).value || '');
28641 return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
28642 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
28643 if (branch && (r.dataset.branch || '') !== branch) return false;
28644 return true;
28645 });
28646 }
28647
28648 // ── Pagination ────────────────────────────────────────────────────────
28649 function renderPage() {
28650 var filtered = getFilteredRows();
28651 var total = filtered.length;
28652 var totalPages = Math.max(1, Math.ceil(total / perPage));
28653 currentPage = Math.min(currentPage, totalPages);
28654 var start = (currentPage - 1) * perPage;
28655 var end = Math.min(start + perPage, total);
28656 var shown = {};
28657 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
28658 Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
28659 r.style.display = shown[r.dataset.run] ? '' : 'none';
28660 });
28661 var rl = document.getElementById('page-range-label');
28662 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
28663 var info = document.getElementById('pagination-info');
28664 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
28665 var btns = document.getElementById('pagination-btns');
28666 if (!btns) return;
28667 btns.innerHTML = '';
28668 function makeBtn(lbl, pg, active, disabled) {
28669 var b = document.createElement('button');
28670 b.className = 'pg-btn' + (active ? ' active' : '');
28671 b.textContent = lbl; b.disabled = disabled;
28672 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
28673 return b;
28674 }
28675 btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
28676 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
28677 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
28678 btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
28679 }
28680
28681 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
28682 window.applyFilters = function() { currentPage = 1; renderPage(); };
28683
28684 // ── Sorting ───────────────────────────────────────────────────────────
28685 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
28686 function doSort(col, type, order) {
28687 var tbody = document.getElementById('compare-tbody');
28688 if (!tbody) return;
28689 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
28690 rows.sort(function(a, b) {
28691 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
28692 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
28693 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
28694 return va < vb ? 1 : va > vb ? -1 : 0;
28695 });
28696 rows.forEach(function(r) { tbody.appendChild(r); });
28697 currentPage = 1; renderPage();
28698 }
28699 sortHeaders.forEach(function(th) {
28700 th.addEventListener('click', function(e) {
28701 if (e.target.classList.contains('col-resize-handle')) return;
28702 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
28703 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
28704 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
28705 th.classList.add('sort-' + sortOrder);
28706 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
28707 doSort(col, type, sortOrder);
28708 });
28709 });
28710
28711 // Apply default sort (timestamp desc) on initial load
28712 (function() {
28713 var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
28714 if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '\u2193'; doSort('timestamp', 'str', 'desc'); }
28715 })();
28716
28717 // ── Column resize ─────────────────────────────────────────────────────
28718 (function() {
28719 var table = document.getElementById('compare-table');
28720 if (!table) return;
28721 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
28722 var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
28723 ths.forEach(function(th, i) {
28724 var handle = th.querySelector('.col-resize-handle');
28725 if (!handle || !cols[i]) return;
28726 var startX, startW;
28727 handle.addEventListener('mousedown', function(e) {
28728 e.stopPropagation(); e.preventDefault();
28729 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
28730 handle.classList.add('dragging');
28731 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
28732 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
28733 document.addEventListener('mousemove', onMove);
28734 document.addEventListener('mouseup', onUp);
28735 });
28736 });
28737 })();
28738
28739 // ── Full-commit hover tooltip ─────────────────────────────────────────
28740 // The commit chips live inside an overflow:auto table wrapper, which would
28741 // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
28742 // (escaping the scroll container) and follow the cursor. Event delegation
28743 // keeps it working after pagination/sorting re-renders the rows.
28744 (function() {
28745 var tip = document.createElement('div');
28746 tip.className = 'commit-tip';
28747 tip.setAttribute('role', 'tooltip');
28748 document.body.appendChild(tip);
28749 var shown = false;
28750 function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
28751 function place(e) {
28752 var pad = 14, r = tip.getBoundingClientRect();
28753 var x = e.clientX + pad, y = e.clientY + pad;
28754 if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
28755 if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
28756 tip.style.left = x + 'px'; tip.style.top = y + 'px';
28757 }
28758 function hide() { tip.style.display = 'none'; shown = false; }
28759 document.addEventListener('mouseover', function(e) {
28760 var chip = chipFrom(e.target);
28761 if (!chip) return;
28762 var full = chip.getAttribute('data-full-commit');
28763 if (!full) return;
28764 tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
28765 });
28766 document.addEventListener('mousemove', function(e) {
28767 if (!shown) return;
28768 if (chipFrom(e.target)) place(e); else hide();
28769 });
28770 document.addEventListener('mouseout', function(e) {
28771 if (chipFrom(e.target)) hide();
28772 });
28773 })();
28774
28775 // ── Reset view ────────────────────────────────────────────────────────
28776 window.resetView = function() {
28777 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
28778 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
28779 sortCol = null; sortOrder = 'asc';
28780 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
28781 var tbody = document.getElementById('compare-tbody');
28782 if (tbody) {
28783 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
28784 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
28785 rows.forEach(function(r) { tbody.appendChild(r); });
28786 }
28787 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
28788 var table = document.getElementById('compare-table');
28789 currentPage = 1; renderPage();
28790 currentPage = 1; renderPage();
28791 };
28792
28793 renderPage();
28794 buildCompareAllBar();
28795
28796 // ── Row selection state ───────────────────────────────────────────────
28797 var selected = [];
28798 var lockedProject = null; // project label of first selected scan
28799
28800 function updateCompareBtn() {
28801 var btn = document.getElementById('compare-btn');
28802 var cnt = document.getElementById('sel-count');
28803 if (!btn) return;
28804 btn.disabled = selected.length < 2;
28805 if (cnt) cnt.textContent = selected.length;
28806 }
28807
28808 function applyProjectLock() {
28809 var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
28810 allRows.forEach(function(r) {
28811 if (lockedProject === null) {
28812 r.classList.remove('row-locked');
28813 } else {
28814 var proj = r.dataset.project || '';
28815 if (proj !== lockedProject) {
28816 r.classList.add('row-locked');
28817 } else {
28818 r.classList.remove('row-locked');
28819 }
28820 }
28821 });
28822 }
28823
28824 function toggleRow(row) {
28825 if (row.classList.contains('row-locked')) return;
28826 var vid = row.dataset.vid || row.dataset.run;
28827 var idx = selected.indexOf(vid);
28828 if (idx >= 0) {
28829 selected.splice(idx, 1);
28830 row.classList.remove('selected');
28831 var b = document.getElementById('badge-' + vid);
28832 if (b) b.textContent = '';
28833 // Release project lock if nothing selected
28834 if (selected.length === 0) lockedProject = null;
28835 } else {
28836 // Set project lock on first selection
28837 if (selected.length === 0) lockedProject = row.dataset.project || null;
28838 selected.push(vid);
28839 row.classList.add('selected');
28840 }
28841 selected.forEach(function(v, i) {
28842 var b = document.getElementById('badge-' + v);
28843 if (b) b.textContent = i + 1;
28844 });
28845 applyProjectLock();
28846 updateCompareBtn();
28847 buildScopePanel();
28848 }
28849
28850 // ── Compare-All bar ───────────────────────────────────────────────────
28851 function buildCompareAllBar() {
28852 var bar = document.getElementById('compare-all-bar');
28853 if (!bar) return;
28854 // Group all rows by project label.
28855 var groups = {};
28856 var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
28857 // Use all rows from the source data (not just visible).
28858 var allRowsAll = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
28859 // We need ALL rows across all pages, not just the rendered ones.
28860 // Use the underlying allRows array that the pagination JS also uses.
28861 var sourceRows = window._allCompareRows || allRowsAll;
28862 sourceRows.forEach(function(r) {
28863 var proj = r.dataset.project || '';
28864 var vid = r.dataset.vid || r.dataset.run || '';
28865 if (!proj || !vid) return;
28866 if (!groups[proj]) groups[proj] = { ids: [], ts: [] };
28867 groups[proj].ids.push(vid);
28868 groups[proj].ts.push(parseInt(r.dataset.sortTs || '0', 10) || 0);
28869 });
28870 // Build buttons for each project with >= 2 scans.
28871 var keys = Object.keys(groups).filter(function(k) { return groups[k].ids.length >= 2; });
28872 if (!keys.length) { bar.style.display = 'none'; return; }
28873 bar.style.display = 'flex';
28874 // Remove old buttons (keep label).
28875 var oldBtns = bar.querySelectorAll('.compare-all-btn');
28876 oldBtns.forEach(function(b) { b.remove(); });
28877 keys.sort();
28878 keys.forEach(function(proj) {
28879 var g = groups[proj];
28880 var btn = document.createElement('button');
28881 btn.className = 'compare-all-btn';
28882 btn.type = 'button';
28883 btn.textContent = proj + ' (' + g.ids.length + ' scans)';
28884 btn.title = 'Compare all ' + g.ids.length + ' scans of ' + proj;
28885 btn.addEventListener('click', function() {
28886 // Sort ids by timestamp (ascending).
28887 var pairs = g.ids.map(function(id, i) { return { id: id, ts: g.ts[i] }; });
28888 pairs.sort(function(a, b) { return a.ts - b.ts; });
28889 var sorted = pairs.map(function(p) { return p.id; });
28890 if (sorted.length === 2) {
28891 window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
28892 } else {
28893 window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
28894 }
28895 });
28896 bar.appendChild(btn);
28897 });
28898 }
28899
28900 // ── Scope panel ───────────────────────────────────────────────────────
28901 var selectedScope = 'all';
28902
28903 function buildScopePanel() {
28904 var panel = document.getElementById('scope-panel');
28905 var opts = document.getElementById('scope-options');
28906 if (!panel || !opts) return;
28907 if (selected.length < 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
28908
28909 // Collect union of submodules from all selected rows.
28910 var allSubs = {};
28911 selected.forEach(function(vid) {
28912 var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
28913 if (!row) return;
28914 (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
28915 });
28916 var subList = Object.keys(allSubs).sort();
28917 if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
28918
28919 panel.classList.remove('hidden');
28920 opts.innerHTML = '';
28921
28922 function makeOption(value, label, title) {
28923 var div = document.createElement('div');
28924 div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
28925 div.dataset.scopeValue = value;
28926 if (title) div.title = title;
28927 var radio = document.createElement('span');
28928 radio.className = 'scope-option-radio';
28929 var lbl = document.createElement('span');
28930 lbl.textContent = label;
28931 div.appendChild(radio);
28932 div.appendChild(lbl);
28933 div.addEventListener('click', function() {
28934 selectedScope = value;
28935 opts.querySelectorAll('.scope-option').forEach(function(o) {
28936 o.classList.toggle('selected', o.dataset.scopeValue === value);
28937 });
28938 });
28939 return div;
28940 }
28941
28942 opts.appendChild(makeOption('all', 'Full scan', 'All files \u2014 super-repo and submodules combined'));
28943 var sep = document.createElement('span');
28944 sep.className = 'scope-option-sep';
28945 opts.appendChild(sep);
28946 opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
28947 subList.forEach(function(s) {
28948 opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule \u201c' + s + '\u201d'));
28949 });
28950 }
28951
28952 function doCompare() {
28953 if (selected.length < 2) return;
28954 if (selected.length === 2) {
28955 // Two-scan delta (existing flow with scope support).
28956 var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
28957 if (selectedScope === 'super') url += '&scope=super';
28958 else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
28959 window.location.href = url;
28960 } else {
28961 // Multi-scan timeline (N >= 3) — pass scope params too.
28962 var url = '/multi-compare?runs=' + selected.map(encodeURIComponent).join(',');
28963 if (selectedScope === 'super') url += '&scope=super';
28964 else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
28965 window.location.href = url;
28966 }
28967 }
28968
28969 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
28970 var cbtn = document.getElementById('compare-btn');
28971 if (cbtn) cbtn.addEventListener('click', doCompare);
28972 var pfEl = document.getElementById('project-filter');
28973 if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
28974 var bfEl = document.getElementById('branch-filter');
28975 if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
28976 var rvBtn = document.getElementById('reset-view-btn');
28977 if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
28978 var ppSel = document.getElementById('per-page-sel');
28979 if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
28980
28981 var cmpTbody = document.getElementById('compare-tbody');
28982 if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
28983 var row = e.target.closest('.compare-row');
28984 if (row) toggleRow(row);
28985 });
28986
28987 (function randomizeWatermarks() {
28988 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
28989 if (!wms.length) return;
28990 var placed = [];
28991 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;}
28992 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];}
28993 var half=Math.floor(wms.length/2);
28994 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;});
28995 })();
28996
28997 (function spawnCodeParticles() {
28998 var container = document.getElementById('code-particles');
28999 if (!container) return;
29000 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'];
29001 for (var i = 0; i < 38; i++) {
29002 (function(idx) {
29003 var el = document.createElement('span');
29004 el.className = 'code-particle';
29005 el.textContent = snippets[idx % snippets.length];
29006 var left = Math.random() * 94 + 2;
29007 var top = Math.random() * 88 + 6;
29008 var dur = (Math.random() * 10 + 9).toFixed(1);
29009 var delay = (Math.random() * 18).toFixed(1);
29010 var rot = (Math.random() * 26 - 13).toFixed(1);
29011 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
29012 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';
29013 container.appendChild(el);
29014 })(i);
29015 }
29016 })();
29017
29018 // ── Watched folder picker ─────────────────────────────────────────────
29019 (function(){
29020 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');};
29021 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);
29022 })();
29023 (function() {
29024 var btn = document.getElementById('add-watched-btn');
29025 if (!btn) return;
29026 btn.addEventListener('click', function() {
29027 fetch('/pick-directory?kind=reports')
29028 .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
29029 .then(function(data) {
29030 if (!data.cancelled && data.selected_path) {
29031 var form = document.createElement('form');
29032 form.method = 'POST';
29033 form.action = '/watched-dirs/add';
29034 var ri = document.createElement('input');
29035 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
29036 var fi = document.createElement('input');
29037 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
29038 form.appendChild(ri); form.appendChild(fi);
29039 document.body.appendChild(form);
29040 if (window.__scanOverlay) window.__scanOverlay();
29041 form.submit();
29042 }
29043 })
29044 .catch(function(e) { alert('Could not open folder picker: ' + e); });
29045 });
29046 })();
29047
29048 // ── Submodule chip truncation ─────────────────────────────────────────
29049 document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
29050 var chips = cell.querySelectorAll('.submod-chip');
29051 var MAX = 4;
29052 if (chips.length <= MAX) return;
29053 for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
29054 var badge = document.createElement('span');
29055 badge.className = 'submod-overflow-badge';
29056 badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
29057 badge.textContent = '+' + (chips.length - MAX) + ' more';
29058 cell.appendChild(badge);
29059 cell.style.maxHeight = 'none';
29060 });
29061 })();
29062 </script>
29063 <script nonce="{{ csp_nonce }}">
29064 (function(){
29065 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'}];
29066 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);});}
29067 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
29068 function init(){
29069 var btn=document.getElementById('settings-btn');if(!btn)return;
29070 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
29071 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>';
29072 document.body.appendChild(m);
29073 var g=document.getElementById('scheme-grid');
29074 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);});
29075 var cl=document.getElementById('settings-close');
29076 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);});})();
29077 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');});
29078 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
29079 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
29080 }
29081 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
29082 }());
29083 </script>
29084 <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]';
29085 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;}
29086 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>
29087</body>
29088</html>
29089"##,
29090 ext = "html"
29091)]
29092struct CompareSelectTemplate {
29093 version: &'static str,
29094 entries: Vec<HistoryEntryRow>,
29095 total_scans: usize,
29096 watched_dirs: Vec<String>,
29097 csp_nonce: String,
29098 server_mode: bool,
29099}
29100
29101#[derive(Template)]
29104#[template(
29105 source = r##"
29106<!doctype html>
29107<html lang="en">
29108<head>
29109 <meta charset="utf-8">
29110 <meta name="viewport" content="width=device-width, initial-scale=1">
29111 <title>OxideSLOC | Scan Delta</title>
29112 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
29113 <style nonce="{{ csp_nonce }}">
29114 :root {
29115 --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
29116 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
29117 --nav:#283790; --nav-2:#013e6b;
29118 --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
29119 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
29120 --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
29121 }
29122 body.dark-theme {
29123 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
29124 --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
29125 }
29126 *{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;}
29127 .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);}
29128 .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;}
29129 .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));}
29130 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
29131 .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;}
29132 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
29133 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
29134 @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; } }
29135 .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;}
29136 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
29137 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
29138 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
29139 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
29140 .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;}
29141 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
29142 .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);}
29143 .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;}
29144 .settings-close:hover{color:var(--text);background:var(--surface-2);}
29145 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
29146 .settings-modal-body{padding:14px 16px 16px;}
29147 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
29148 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
29149 .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;}
29150 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
29151 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
29152 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
29153 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
29154 .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;}
29155 .tz-select:focus{border-color:var(--oxide);}
29156 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
29157 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
29158 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
29159 .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;}
29160 .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
29161 .hero-body{display:block;}
29162 .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;}
29163 .btn-back:hover{background:var(--line);}
29164 h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
29165 h2{margin:0 0 14px;font-size:18px;font-weight:750;}
29166 .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;}
29167 .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
29168 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;}
29169 .muted{color:var(--muted);font-size:14px;}
29170 .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
29171 .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;}
29172 .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
29173 .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
29174 .vpill-arrow{font-size:20px;color:var(--muted);}
29175 .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
29176 .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
29177 .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;}
29178 .delta-card.delta-card-wide{padding:22px 24px;}
29179 .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
29180 body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
29181 .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
29182 .delta-card-from{font-size:15px;color:var(--muted);}
29183 .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
29184 .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
29185 .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
29186 .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%;}
29187 .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;}
29188 .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
29189 .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
29190 .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
29191 .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
29192 body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
29193 body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
29194 .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;}
29195 .meta-card-commit:hover{color:var(--oxide);}
29196 .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
29197 .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
29198 .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
29199 .meta-value{color:var(--text);font-size:13px;}
29200 .cmp-author-handle{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}
29201 .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;}
29202 .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);}
29203 .delta-card:hover .dc-tip{display:block;}
29204 .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;}
29205 .export-btn:hover{background:var(--line);}
29206 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
29207 .panel-title{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}
29208 .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
29209 .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
29210 .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
29211 .delta-card-change.zero{color:var(--muted);background:transparent;}
29212 .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
29213 .delta-card-pct.pos{color:var(--pos);}
29214 .delta-card-pct.neg{color:var(--neg);}
29215 .delta-card-pct.zero{color:var(--muted);}
29216 .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
29217 .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;}
29218 .insight-card.insight-flag{border-color:var(--oxide);}
29219 .insight-card:hover .dc-tip{display:block;}
29220 .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
29221 .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
29222 .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
29223 .insight-label.flag{color:var(--oxide);}
29224 .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
29225 .insight-val.pos{color:var(--pos);}
29226 .insight-val.neg{color:var(--neg);}
29227 .insight-val.high{color:#c0392a;}
29228 .insight-val.med{color:#926000;}
29229 .insight-val.low{color:var(--pos);}
29230 body.dark-theme .insight-val.high{color:#ff6b6b;}
29231 body.dark-theme .insight-val.med{color:#f0c060;}
29232 .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
29233 .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
29234 .fc-row{display:flex;align-items:center;gap:8px;}
29235 .fc-count{font-weight:800;font-size:16px;min-width:28px;}
29236 .fc-label{color:var(--muted);}
29237 .fc-modified .fc-count{color:#926000;}
29238 .fc-added .fc-count{color:var(--pos);}
29239 .fc-removed .fc-count{color:var(--neg);}
29240 .fc-unchanged .fc-count{color:var(--muted);}
29241 .fc-total{border-top:1px solid var(--line);margin-top:3px;padding-top:5px;}
29242 .fc-total .fc-count{color:var(--text);}
29243 .fc-total .fc-label{font-weight:700;}
29244 body.dark-theme .fc-modified .fc-count{color:#f0c060;}
29245 .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
29246 .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
29247 .chip.modified{background:#fff2d8;color:#926000;}
29248 .chip.added{background:#e8f5ed;color:#1a8f47;}
29249 .chip.removed{background:#fdeaea;color:#b33b3b;}
29250 .chip.unchanged{background:var(--surface-2);color:var(--muted);}
29251 body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
29252 body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
29253 body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
29254 .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
29255 .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
29256 .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;}
29257 .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
29258 .tab-btn:hover:not(.active){background:var(--line);}
29259 .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;}
29260 .btn-reset:hover{background:var(--line);}
29261 .table-wrap{width:100%;overflow-x:auto;}
29262 table{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}
29263 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);}
29264 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
29265 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
29266 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
29267 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
29268 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
29269 td{padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:middle;white-space:nowrap;}
29270 tr:last-child td{border-bottom:none;}
29271 tr:hover td{background:var(--surface-2);}
29272 .col-num{text-align:right;font-variant-numeric:tabular-nums;}
29273 #delta-table th:nth-child(n+4),#delta-table td:nth-child(n+4){text-align:right;font-variant-numeric:tabular-nums;}
29274 #delta-table th:last-child,#delta-table td:last-child{padding-right:14px;}
29275 /* Fixed layout: column widths come from the colgroup, not from scanning every
29276 row. With auto layout a large file matrix forces the browser to re-measure
29277 all cells on each reflow, which freezes the page during sort/resize. */
29278 #delta-table{table-layout:fixed;}
29279 #delta-table col:nth-child(1){width:32%;}
29280 #delta-table col:nth-child(2){width:11%;}
29281 #delta-table col:nth-child(3){width:11%;}
29282 #delta-table col:nth-child(4){width:16%;}
29283 #delta-table col:nth-child(5){width:10%;}
29284 #delta-table col:nth-child(6){width:10%;}
29285 #delta-table col:nth-child(7){width:10%;}
29286 tr.row-added td{background:rgba(26,143,71,0.04);}
29287 tr.row-removed td{background:rgba(179,59,59,0.06);}
29288 tr.row-modified td{background:rgba(146,96,0,0.04);}
29289 tr.row-unchanged td{color:var(--muted);}
29290 tr.row-unchanged .status-badge{opacity:.65;}
29291 .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;}
29292 .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
29293 .status-badge.added{background:#e8f5ed;color:#1a8f47;}
29294 .status-badge.removed{background:#fdeaea;color:#b33b3b;}
29295 .status-badge.modified{background:#fff2d8;color:#926000;}
29296 .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
29297 body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
29298 body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
29299 body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
29300 .delta-val{font-weight:700;}
29301 .delta-val.pos{color:var(--pos);}
29302 .delta-val.neg{color:var(--neg);}
29303 .delta-val.zero{color:var(--muted);}
29304 .from-to{display:flex;align-items:center;gap:5px;white-space:nowrap;font-size:13px;}
29305 .from-to strong{color:var(--text);font-weight:700;}
29306 .from-to .ft-sep{color:var(--muted-2);font-size:11px;}
29307 .from-to .ft-absent{color:var(--muted);font-weight:600;}
29308 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
29309 .site-footer a{color:var(--muted);}
29310 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;}
29311 body.pdf-mode{background:#fff!important;}
29312 body.pdf-mode .page{padding:4px 6px 4px!important;}
29313 @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
29314 @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
29315 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
29316 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
29317 .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;}
29318 .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;}
29319 .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;}
29320 @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));}}
29321 .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
29322 .path-link:hover{color:var(--oxide-2);}
29323 .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
29324 a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
29325 a.vpill-id:hover{color:var(--oxide);}
29326 .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
29327 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
29328 .pagination-info{font-size:13px;color:var(--muted);}
29329 .pagination-btns{display:flex;gap:6px;}
29330 .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;}
29331 .pg-btn:hover:not(:disabled){background:var(--line);}
29332 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29333 .pg-btn:disabled{opacity:.35;cursor:default;}
29334 .per-page-label{font-size:13px;color:var(--muted);}
29335 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;}
29336 .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29337 .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
29338 .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
29339 .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
29340 .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
29341 .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
29342 .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
29343 .tab-btn.tab-unchanged{color:var(--muted);}
29344 body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
29345 body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
29346 body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
29347 .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;}
29348 .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;}
29349 .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
29350 .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;}
29351 .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
29352 .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;}
29353 .submod-scope-btn:hover{background:var(--line);}
29354 .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29355 .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
29356 .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
29357 @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
29358 .ic-card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
29359 body.dark-theme .ic-card{background:var(--surface-2);}
29360 .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
29361 .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}
29362 .ic-leg-item{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}
29363 .ic-leg-item:hover{background:rgba(211,122,76,0.08);}
29364 .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
29365 .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);}
29366 .ic-card-h2-row{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}
29367 .ic-card-h2-row .ic-card-h2{margin:0;}
29368 .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;}
29369 .ic-expand-btn:hover{background:var(--surface-2);color:var(--text);}
29370 .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;}
29371 .ic-svg-modal-ov.open{display:flex;}
29372 .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);}
29373 body.dark-theme .ic-svg-modal{background:var(--surface-2);}
29374 .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);}
29375 .ic-svg-modal-title{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}
29376 .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;}
29377 .ic-svg-modal-close:hover{background:var(--line);}
29378 .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;}
29379 .chart-metric-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
29380 .chart-metric-btn:hover:not(.active){background:var(--line);}
29381 .chart-wrap{width:100%;overflow-x:auto;}
29382 #cmp-tl-svg{display:block;width:100%;}
29383 .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);}
29384 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
29385 #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;}
29386 </style>
29387</head>
29388<body>
29389 {{ loading_overlay|safe }}
29390 <div class="background-watermarks" aria-hidden="true">
29391 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29392 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29393 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29394 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29395 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29396 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29397 </div>
29398 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
29399 <div class="top-nav">
29400 <div class="top-nav-inner">
29401 <a class="brand" href="/">
29402 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
29403 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan Delta</div></div>
29404 </a>
29405 <div class="nav-right">
29406 <a class="nav-pill" href="/">Home</a>
29407 <div class="nav-dropdown">
29408 <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>
29409 <div class="nav-dropdown-menu">
29410 <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>
29411 </div>
29412 </div>
29413 <a class="nav-pill" style="background:rgba(255,255,255,0.22);" href="/compare-scans">Compare Scans</a>
29414 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
29415 <div class="nav-dropdown">
29416 <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>
29417 <div class="nav-dropdown-menu">
29418 <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>
29419 </div>
29420 </div>
29421 <div class="server-status-wrap" id="server-status-wrap">
29422 <div class="nav-pill server-online-pill" id="server-status-pill">
29423 <span class="status-dot" id="status-dot"></span>
29424 <span id="server-status-label">Server</span>
29425 <span id="server-ping-ms" style="margin-left:5px;opacity:0.75;font-size:10px;"></span>
29426 </div>
29427 <div class="server-status-tip">
29428 OxideSLOC is running — accessible on your network.
29429 <span id="server-tip-ping" style="display:block;margin-top:4px;font-size:11px;opacity:0.75;"></span>
29430 </div>
29431 </div>
29432 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
29433 <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>
29434 </button>
29435 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
29436 <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>
29437 <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>
29438 </button>
29439 </div>
29440 </div>
29441 </div>
29442
29443 <div class="page">
29444 <section class="hero">
29445 <div class="hero-header">
29446 <div>
29447 <h1 class="delta-title">Scan Delta</h1>
29448 <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
29449 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:6px;">
29450 {% if let Some(sub) = active_submodule %}
29451 <span class="muted" style="font-size:16px;">Submodule <strong>{{ sub }}</strong> — two scans of</span>
29452 {% else if super_scope_active %}
29453 <span class="muted" style="font-size:16px;">Super-repo only (submodules excluded) — two scans of</span>
29454 {% else %}
29455 <span class="muted" style="font-size:16px;">Full scan — two scans of</span>
29456 {% endif %}
29457 <a class="path-link" id="project-path-link" data-folder="{{ project_path }}" href="#" style="font-size:16px;font-weight:700;">{{ project_path }}</a>
29458 </div>
29459 </div>
29460 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px;flex-shrink:0;">
29461 <a class="btn-back" href="/compare-scans">
29462 <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>
29463 Compare Scans
29464 </a>
29465 <div class="export-group" style="margin-top:12px;">
29466 <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>
29467 <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>
29468 </div>
29469 </div>
29470 </div>
29471 {% if has_any_submodule_data %}
29472 <div class="submod-scope-bar">
29473 <span class="submod-scope-label">
29474 <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>
29475 Scope:
29476 </span>
29477 <div class="submod-scope-divider"></div>
29478 <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
29479 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}"
29480 title="All files — super-repo and all submodules combined">Full scan</a>
29481 <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
29482 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&scope=super"
29483 title="Only files that are not part of any submodule">Super-repo only</a>
29484 {% for sub in submodule_options %}
29485 <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
29486 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&sub={{ sub }}"
29487 title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
29488 {% endfor %}
29489 </div>
29490 {% endif %}
29491 <div class="hero-body">
29492 <div class="meta-strip">
29493 <div class="delta-card delta-card-meta">
29494 <div class="meta-card-header">
29495 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Baseline</div>
29496 <div class="meta-card-project-col">
29497 <div class="meta-card-project">{{ project_name }}</div>
29498 {% if has_any_submodule_data %}
29499 {% if let Some(sub) = active_submodule %}
29500 <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>
29501 {% else if super_scope_active %}
29502 <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>
29503 {% else %}
29504 <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>
29505 {% endif %}
29506 {% endif %}
29507 </div>
29508 </div>
29509 {% if !baseline_git_commit.is_empty() %}
29510 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
29511 {% else %}
29512 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
29513 {% endif %}
29514 <div class="meta-card-rows">
29515 <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>
29516 <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>
29517 <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>
29518 <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>
29519 {% if let Some(tags) = baseline_git_tags %}
29520 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
29521 {% endif %}
29522 </div>
29523 </div>
29524 <div class="delta-card delta-card-meta">
29525 <div class="meta-card-header">
29526 <div class="delta-card-label" style="margin-bottom:0;font-size:26px;letter-spacing:.04em;">Current</div>
29527 <div class="meta-card-project-col">
29528 <div class="meta-card-project">{{ project_name }}</div>
29529 {% if has_any_submodule_data %}
29530 {% if let Some(sub) = active_submodule %}
29531 <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>
29532 {% else if super_scope_active %}
29533 <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>
29534 {% else %}
29535 <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>
29536 {% endif %}
29537 {% endif %}
29538 </div>
29539 </div>
29540 {% if !current_git_commit.is_empty() %}
29541 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
29542 {% else %}
29543 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
29544 {% endif %}
29545 <div class="meta-card-rows">
29546 <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>
29547 <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>
29548 <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>
29549 <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>
29550 {% if let Some(tags) = current_git_tags %}
29551 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
29552 {% endif %}
29553 </div>
29554 </div>
29555 </div>
29556 <div class="delta-strip">
29557 <div class="delta-card">
29558 <div class="dc-tip">Executable source lines.<br>Excludes comments and blanks.<br>Positive delta = more code written.</div>
29559 <div class="delta-card-label">Code lines</div>
29560 <div class="delta-card-from">Before: {{ baseline_code_fmt }}</div>
29561 <div class="delta-card-to">{{ current_code_fmt }}</div>
29562 {% 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>
29563 {% 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>
29564 {% else %}<div class="delta-card-pct zero">±0%</div>
29565 {% endif %}
29566 </div>
29567 <div class="delta-card">
29568 <div class="dc-tip">Source files where language detection succeeded.<br>Changes reflect files added, removed, or reclassified between scans.</div>
29569 <div class="delta-card-label">Files analyzed</div>
29570 <div class="delta-card-from">Before: {{ baseline_files_fmt }}</div>
29571 <div class="delta-card-to">{{ current_files_fmt }}</div>
29572 {% 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>
29573 {% 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>
29574 {% else %}<div class="delta-card-pct zero">±0%</div>
29575 {% endif %}
29576 </div>
29577 <div class="delta-card">
29578 <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>
29579 <div class="delta-card-label">Comment lines</div>
29580 <div class="delta-card-from">Before: {{ baseline_comments_fmt }}</div>
29581 <div class="delta-card-to">{{ current_comments_fmt }}</div>
29582 {% 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>
29583 {% 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>
29584 {% else %}<div class="delta-card-pct zero">±0%</div>
29585 {% endif %}
29586 </div>
29587 {{ coverage_delta_card|safe }}
29588 <div class="delta-card delta-card-wide">
29589 <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>
29590 <div class="delta-card-label">File changes</div>
29591 <div class="file-changes-grid">
29592 <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified|commas }}</span><span class="fc-label">Modified</span></div>
29593 <div class="fc-row fc-added"><span class="fc-count">{{ files_added|commas }}</span><span class="fc-label">Added</span></div>
29594 <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed|commas }}</span><span class="fc-label">Removed</span></div>
29595 <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged|commas }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
29596 <div class="fc-row fc-total"><span class="fc-count">{{ files_total|commas }}</span><span class="fc-label">Total (modified + added + removed + unchanged)</span></div>
29597 </div>
29598 </div>
29599 </div>
29600 <div class="insights-panel">
29601 <div class="insight-card">
29602 <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>
29603 <div class="insight-label">Lines Added</div>
29604 <div class="insight-val pos">+{{ code_lines_added }}</div>
29605 <div class="insight-sub">New or grown source lines</div>
29606 </div>
29607 <div class="insight-card">
29608 <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>
29609 <div class="insight-label">Lines Removed</div>
29610 <div class="insight-val neg">−{{ code_lines_removed }}</div>
29611 <div class="insight-sub">Deleted or shrunk source lines</div>
29612 </div>
29613 <div class="insight-card">
29614 <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>
29615 <div class="insight-label">Lines Modified</div>
29616 <div class="insight-val">{{ code_lines_modified }}</div>
29617 <div class="insight-sub">Code lines in modified files</div>
29618 </div>
29619 <div class="insight-card">
29620 <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>
29621 <div class="insight-label">Lines Unmodified</div>
29622 <div class="insight-val">{{ code_lines_unmodified }}</div>
29623 <div class="insight-sub">Code lines in unchanged files</div>
29624 </div>
29625 <div class="insight-card">
29626 <div class="dc-tip up">Sum of the added, removed, modified, and unmodified code-line metrics across the two scans.</div>
29627 <div class="insight-label">Lines Total</div>
29628 <div class="insight-val">{{ code_lines_total }}</div>
29629 <div class="insight-sub">Added + removed + modified + unmodified</div>
29630 </div>
29631 <div class="insight-card">
29632 <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>
29633 <div class="insight-label">Churn Rate</div>
29634 <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
29635 <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>
29636 </div>
29637 {% if scope_flag %}
29638 <div class="insight-card insight-flag">
29639 <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>
29640 <div class="insight-label flag">Scope Signal</div>
29641 <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
29642 <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>
29643 </div>
29644 {% endif %}
29645 </div>
29646 </div>
29647 </section>
29648
29649 <section class="panel" id="inline-charts-section">
29650 <div class="panel-title">Scan Delta Charts</div>
29651 <div class="ic-grid">
29652 <div class="ic-card" style="grid-column:span 2">
29653 <div class="ic-card-h2-row">
29654 <span class="ic-card-h2">Timeline</span>
29655 <div class="cmp-tl-btns" style="display:flex;gap:6px;flex-wrap:wrap;">
29656 <button class="chart-metric-btn active" data-cmp-metric="code">Code Lines</button>
29657 <button class="chart-metric-btn" data-cmp-metric="files">Files</button>
29658 <button class="chart-metric-btn" data-cmp-metric="comments">Comments</button>
29659 <button class="chart-metric-btn" data-cmp-metric="tests">Tests</button>
29660 <button class="chart-metric-btn" data-cmp-metric="cov">Coverage</button>
29661 </div>
29662 <button class="ic-expand-btn" data-expand-src="cmp-tl-svg" data-expand-title="Timeline">⤢ Full View</button>
29663 </div>
29664 <div class="chart-wrap"><svg id="cmp-tl-svg" width="100%" height="280"></svg></div>
29665 </div>
29666 <div class="ic-card">
29667 <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>
29668 <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>
29669 <div id="ic-c1"></div>
29670 </div>
29671 <div class="ic-card" id="ic-lang-card">
29672 <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>
29673 <div id="ic-c3"></div>
29674 </div>
29675 <div class="ic-card">
29676 <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>
29677 <div id="ic-c2"></div>
29678 </div>
29679 <div class="ic-card">
29680 <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>
29681 <div id="ic-c4"></div>
29682 </div>
29683 </div>
29684 <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
29685 <div class="ic-svg-modal">
29686 <div class="ic-svg-modal-hdr">
29687 <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
29688 <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">× Close</button>
29689 </div>
29690 <div id="ic-svg-modal-body"></div>
29691 </div>
29692 </div>
29693 </section>
29694
29695 <section class="panel">
29696 <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>
29697 <div style="display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px;margin-bottom:14px;">
29698 <div class="filter-tabs" style="display:flex;gap:6px;flex-wrap:wrap;">
29699 <button class="tab-btn tab-all active" data-filter="all">All ({{ (files_modified + files_added + files_removed + files_unchanged)|commas }})</button>
29700 <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified|commas }})</button>
29701 <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added|commas }})</button>
29702 <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed|commas }})</button>
29703 <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged|commas }})</button>
29704 </div>
29705 <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px;">
29706 <span class="delta-note">* Δ = delta (change from baseline → current)</span>
29707 <div class="export-group">
29708 <button type="button" class="export-btn" id="delta-reset-btn">↻ Reset</button>
29709 <button type="button" class="export-btn" id="delta-csv-btn">
29710 <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>
29711 CSV
29712 </button>
29713 <button type="button" class="export-btn" id="delta-xls-btn">
29714 <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>
29715 Excel
29716 </button>
29717 </div>
29718 </div>
29719 </div>
29720
29721 <div class="table-wrap">
29722 <table id="delta-table">
29723 <colgroup>
29724 <col>
29725 <col>
29726 <col>
29727 <col>
29728 <col>
29729 <col>
29730 <col>
29731 </colgroup>
29732 <thead>
29733 <tr id="delta-thead">
29734 <th class="sortable" data-sort-col="path" data-sort-type="str">File<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
29735 <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>
29736 <th class="sortable" data-sort-col="status" data-sort-type="str">Status<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
29737 <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>
29738 <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>
29739 <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>
29740 <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>
29741 </tr>
29742 </thead>
29743 <tbody id="delta-tbody">
29744 {% for row in file_rows %}
29745 <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
29746 data-path="{{ row.relative_path }}"
29747 data-language="{{ row.language }}"
29748 data-baseline-code="{{ row.baseline_code }}"
29749 data-current-code="{{ row.current_code }}"
29750 data-code-delta="{{ row.code_delta_str }}"
29751 data-comment-delta="{{ row.comment_delta_str }}"
29752 data-total-delta="{{ row.total_delta_str }}"
29753 data-orig-idx="">
29754 <td title="{{ row.relative_path }}"><span class="file-path">{{ row.relative_path }}</span></td>
29755 <td class="hide-sm">{{ row.language }}</td>
29756 <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
29757 <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>
29758 <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
29759 <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
29760 <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
29761 </tr>
29762 {% endfor %}
29763 </tbody>
29764 </table>
29765 </div>
29766 <div class="pagination">
29767 <span class="pagination-info" id="pg-range-label"></span>
29768 <div class="pagination-btns" id="pg-btns"></div>
29769 <div class="flex-row">
29770 <span class="per-page-label">Show</span>
29771 <select class="per-page" id="per-page-sel">
29772 <option value="10">10 per page</option>
29773 <option value="25" selected>25 per page</option>
29774 <option value="50">50 per page</option>
29775 <option value="100">100 per page</option>
29776 </select>
29777 </div>
29778 </div>
29779 </section>
29780 </div>
29781
29782 <div id="ic-tt"></div>
29783
29784 <footer class="site-footer">
29785 local code analysis - metrics, history and reports
29786 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
29787 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
29788 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
29789 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
29790 · <a href="/api-docs" rel="noopener">REST API</a>
29791 </footer>
29792
29793 <script nonce="{{ csp_nonce }}">
29794 (function () {
29795 var storageKey = 'oxide-sloc-theme';
29796 var body = document.body;
29797 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
29798 var toggle = document.getElementById('theme-toggle');
29799 if (toggle) toggle.addEventListener('click', function () {
29800 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
29801 body.classList.toggle('dark-theme', next === 'dark');
29802 try { localStorage.setItem(storageKey, next); } catch(e) {}
29803 });
29804
29805 (function randomizeWatermarks() {
29806 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
29807 if (!wms.length) return;
29808 var placed = [];
29809 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;}
29810 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];}
29811 var half=Math.floor(wms.length/2);
29812 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;});
29813 })();
29814
29815 (function spawnCodeParticles() {
29816 var container = document.getElementById('code-particles');
29817 if (!container) return;
29818 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'];
29819 for (var i = 0; i < 38; i++) {
29820 (function(idx) {
29821 var el = document.createElement('span');
29822 el.className = 'code-particle';
29823 el.textContent = snippets[idx % snippets.length];
29824 var left = Math.random() * 94 + 2;
29825 var top = Math.random() * 88 + 6;
29826 var dur = (Math.random() * 10 + 9).toFixed(1);
29827 var delay = (Math.random() * 18).toFixed(1);
29828 var rot = (Math.random() * 26 - 13).toFixed(1);
29829 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
29830 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';
29831 container.appendChild(el);
29832 })(i);
29833 }
29834 })();
29835 })();
29836
29837 var activeStatusFilter = 'all';
29838 var deltaPerPage = 25, deltaCurrPage = 1;
29839
29840 function openFolder(path) {
29841 fetch('/open-path?path=' + encodeURIComponent(path))
29842 .then(function (r) { return r.json(); })
29843 .then(function (d) {
29844 if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
29845 })
29846 .catch(function () {});
29847 }
29848
29849 // \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
29850 // The server renders every row once; we lift them into a plain-data array and
29851 // then clear the DOM so only the visible page's <tr>s ever exist. Sorting and
29852 // filtering run on the array (no DOM churn) and each render rebuilds just one
29853 // page (~25 rows). This keeps every interaction O(page) instead of O(all
29854 // files): a 28k-row table previously re-touched every node on each click
29855 // (querySelectorAll x2, appendChild x28k to sort) and froze the page.
29856 var DELTA = [], _deltaView = [], sortCol = null, sortOrder = 'asc';
29857
29858 function parseDeltaNum(str) {
29859 if (!str || str === '\u2014') return 0;
29860 return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().charAt(0) === '-' ? -1 : 1);
29861 }
29862
29863 function captureDelta() {
29864 var tbody = document.getElementById('delta-tbody');
29865 if (!tbody) return;
29866 var rows = tbody.querySelectorAll('.delta-row');
29867 for (var i = 0; i < rows.length; i++) {
29868 var r = rows[i];
29869 DELTA.push({
29870 h: r.innerHTML,
29871 cls: r.className,
29872 path: r.getAttribute('data-path') || '',
29873 lang: r.getAttribute('data-language') || '',
29874 status: r.getAttribute('data-status') || '',
29875 bc: parseFloat(r.getAttribute('data-baseline-code')) || 0,
29876 cc: parseFloat(r.getAttribute('data-current-code')) || 0,
29877 cd: parseDeltaNum(r.getAttribute('data-code-delta')),
29878 cmd: parseDeltaNum(r.getAttribute('data-comment-delta')),
29879 td: parseDeltaNum(r.getAttribute('data-total-delta')),
29880 bcs: r.getAttribute('data-baseline-code') || '',
29881 ccs: r.getAttribute('data-current-code') || '',
29882 cds: r.getAttribute('data-code-delta') || '',
29883 cmds: r.getAttribute('data-comment-delta') || '',
29884 tds: r.getAttribute('data-total-delta') || ''
29885 });
29886 }
29887 tbody.innerHTML = '';
29888 }
29889
29890 function applyDeltaQuery() {
29891 var v = (activeStatusFilter === 'all') ? DELTA.slice()
29892 : DELTA.filter(function(d) { return d.status === activeStatusFilter; });
29893 if (sortCol) {
29894 var asc = sortOrder === 'asc';
29895 v.sort(function(a, b) {
29896 var va, vb;
29897 if (sortCol === 'path') { va = a.path; vb = b.path; }
29898 else if (sortCol === 'language') { va = a.lang; vb = b.lang; }
29899 else if (sortCol === 'status') { va = a.status; vb = b.status; }
29900 else if (sortCol === 'baseline_code') { return asc ? a.bc - b.bc : b.bc - a.bc; }
29901 else if (sortCol === 'code_delta') { return asc ? a.cd - b.cd : b.cd - a.cd; }
29902 else if (sortCol === 'comment_delta') { return asc ? a.cmd - b.cmd : b.cmd - a.cmd; }
29903 else if (sortCol === 'total_delta') { return asc ? a.td - b.td : b.td - a.td; }
29904 else { return 0; }
29905 if (asc) return va < vb ? -1 : va > vb ? 1 : 0;
29906 return va < vb ? 1 : va > vb ? -1 : 0;
29907 });
29908 }
29909 _deltaView = v;
29910 deltaCurrPage = 1;
29911 renderDeltaPage();
29912 }
29913
29914 function renderDeltaPage() {
29915 var total = _deltaView.length;
29916 var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
29917 if (deltaCurrPage > totalPages) deltaCurrPage = totalPages;
29918 if (deltaCurrPage < 1) deltaCurrPage = 1;
29919 var start = (deltaCurrPage - 1) * deltaPerPage;
29920 var end = Math.min(start + deltaPerPage, total);
29921 var tbody = document.getElementById('delta-tbody');
29922 if (tbody) {
29923 var html = '';
29924 for (var i = start; i < end; i++) { var d = _deltaView[i]; html += '<tr class="' + d.cls + '">' + d.h + '</tr>'; }
29925 tbody.innerHTML = html;
29926 }
29927 var rl = document.getElementById('pg-range-label');
29928 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total + ' files' : 'No results';
29929 var btns = document.getElementById('pg-btns');
29930 if (!btns) return;
29931 btns.innerHTML = '';
29932 if (totalPages <= 1) return;
29933 function makeBtn(lbl, pg, active, disabled) {
29934 var b = document.createElement('button');
29935 b.className = 'pg-btn' + (active ? ' active' : '');
29936 b.textContent = lbl; b.disabled = disabled;
29937 if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
29938 return b;
29939 }
29940 btns.appendChild(makeBtn('\u2039', deltaCurrPage - 1, false, deltaCurrPage === 1));
29941 var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
29942 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
29943 btns.appendChild(makeBtn('\u203a', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
29944 }
29945
29946 window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
29947
29948 function filterRows(status, btn) {
29949 activeStatusFilter = status;
29950 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
29951 b.classList.remove('active');
29952 });
29953 if (btn) btn.classList.add('active');
29954 applyDeltaQuery();
29955 }
29956
29957 // ── Sorting ──────────────────────────────────────────────────────────────
29958 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
29959 sortHeaders.forEach(function(th) {
29960 th.addEventListener('click', function(e) {
29961 if (e.target.classList.contains('col-resize-handle')) return;
29962 var col = th.dataset.sortCol;
29963 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
29964 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
29965 th.classList.add('sort-' + sortOrder);
29966 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
29967 applyDeltaQuery();
29968 });
29969 });
29970
29971 // ── Column resize ─────────────────────────────────────────────────────────
29972 (function() {
29973 var table = document.getElementById('delta-table');
29974 if (!table) return;
29975 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
29976 var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
29977 ths.forEach(function(th, i) {
29978 var handle = th.querySelector('.col-resize-handle');
29979 if (!handle || !cols[i]) return;
29980 handle.addEventListener('mousedown', function(e) {
29981 e.stopPropagation(); e.preventDefault();
29982 // Lock every column to its current rendered px width and size the table
29983 // to the column total. With table-layout:fixed + width:100% the table is
29984 // pinned to the container, so widening one <col> only rebalances the rest
29985 // and the drag looks inert; pinning px widths lets the column actually
29986 // grow while the wrapper (overflow-x:auto) scrolls.
29987 var startTableW = 0;
29988 for (var k = 0; k < ths.length; k++) {
29989 if (!cols[k]) continue;
29990 var w = ths[k].getBoundingClientRect().width;
29991 cols[k].style.width = w + 'px';
29992 startTableW += w;
29993 }
29994 table.style.width = startTableW + 'px';
29995 var startX = e.clientX;
29996 var startW = ths[i].getBoundingClientRect().width;
29997 handle.classList.add('dragging');
29998 function onMove(ev) {
29999 var newW = Math.max(40, startW + ev.clientX - startX);
30000 cols[i].style.width = newW + 'px';
30001 table.style.width = (startTableW + (newW - startW)) + 'px';
30002 }
30003 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
30004 document.addEventListener('mousemove', onMove);
30005 document.addEventListener('mouseup', onUp);
30006 });
30007 });
30008 })();
30009
30010 // ── Reset ─────────────────────────────────────────────────────────────────
30011 window.resetDeltaTable = function() {
30012 sortCol = null; sortOrder = 'asc';
30013 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
30014 var table = document.getElementById('delta-table');
30015 if (table) { table.style.width = ''; Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; }); }
30016 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
30017 activeStatusFilter = 'all';
30018 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
30019 var allBtn = document.querySelector('.tab-btn');
30020 if (allBtn) allBtn.classList.add('active');
30021 applyDeltaQuery();
30022 };
30023
30024 // Compact number formatter (shared by the delta table; charts define their own locally)
30025 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();}
30026 function fmtFull(n){return Number(n).toLocaleString();}
30027
30028 // Format from-to numbers with fmt() and ensure zero→dash for added/removed
30029 function fmtFromTo() {
30030 var tbody = document.getElementById('delta-tbody');
30031 if (!tbody) return;
30032 tbody.querySelectorAll('.delta-row').forEach(function(row) {
30033 var status = row.dataset.status || '';
30034 var ft = row.querySelector('.from-to');
30035 if (!ft) return;
30036 var bv = parseInt(ft.getAttribute('data-baseline') || '0', 10);
30037 var cv = parseInt(ft.getAttribute('data-current') || '0', 10);
30038 var strongs = ft.querySelectorAll('strong');
30039 // Apply fmt() to non-absent strong values
30040 strongs.forEach(function(el) {
30041 var n = parseInt(el.textContent, 10);
30042 if (!isNaN(n)) el.textContent = fmtFull(n);
30043 });
30044 // Safety: force dash for genuinely absent sides
30045 if (status === 'added' && bv === 0) {
30046 var bs = ft.querySelector('strong:first-of-type');
30047 if (bs && bs.textContent === '0') {
30048 bs.outerHTML = '<span class="ft-absent">\u2014</span>';
30049 }
30050 }
30051 if (status === 'removed' && cv === 0) {
30052 var cs = ft.querySelector('strong:last-of-type');
30053 if (cs && cs.textContent === '0') {
30054 cs.outerHTML = '<span class="ft-absent">\u2014</span>';
30055 }
30056 }
30057 });
30058 }
30059 // Initialize: format the server-rendered rows, lift them into the data model
30060 // (which also clears the DOM), then render only the first page.
30061 fmtFromTo();
30062 captureDelta();
30063 applyDeltaQuery();
30064
30065 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
30066 (function() {
30067 Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
30068 btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
30069 });
30070 var resetBtn = document.getElementById('delta-reset-btn');
30071 if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
30072 var csvBtn = document.getElementById('delta-csv-btn');
30073 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
30074 var xlsBtn = document.getElementById('delta-xls-btn');
30075 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
30076 // ── Export helpers (image-inlining + pdf-mode) ────────────────────────────
30077 function sdFetchUri(path) {
30078 return fetch(path).then(function(r){return r.blob();}).then(function(b){
30079 return new Promise(function(res){var rd=new FileReader();rd.onload=function(){res(rd.result);};rd.onerror=function(){res('');};rd.readAsDataURL(b);});
30080 }).catch(function(){return '';});
30081 }
30082 function sdInlineImgs(html, cb) {
30083 var paths=[], seen={};
30084 html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){if(!seen[p]){seen[p]=1;paths.push(p);}return _;});
30085 if(!paths.length){cb(html);return;}
30086 Promise.all(paths.map(function(p){return sdFetchUri(p).then(function(u){return{p:p,u:u};});}))
30087 .then(function(rs){rs.forEach(function(r){if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');});cb(html);})
30088 .catch(function(){cb(html);});
30089 }
30090 function buildFullPageHtml(pdfMode) {
30091 if(pdfMode) document.body.classList.add('pdf-mode');
30092 var saved = deltaPerPage; deltaPerPage = 999999; deltaCurrPage = 1;
30093 renderDeltaPage();
30094 var html = document.documentElement.outerHTML;
30095 deltaPerPage = saved; deltaCurrPage = 1; renderDeltaPage();
30096 if(pdfMode) document.body.classList.remove('pdf-mode');
30097 return html;
30098 }
30099 var chartsBtn = document.getElementById('delta-charts-btn');
30100 if (chartsBtn) chartsBtn.addEventListener('click', function() {
30101 var btn=chartsBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
30102 sdInlineImgs(buildFullPageHtml(false), function(html) {
30103 var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
30104 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
30105 a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
30106 btn.disabled=false;btn.innerHTML=orig;
30107 });
30108 });
30109 var pageHtmlBtn = document.getElementById('page-export-html-btn');
30110 if (pageHtmlBtn) pageHtmlBtn.addEventListener('click', function() {
30111 var btn=pageHtmlBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
30112 sdInlineImgs(buildFullPageHtml(false), function(html) {
30113 var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
30114 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
30115 a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
30116 btn.disabled=false;btn.innerHTML=orig;
30117 });
30118 });
30119 // PDF export — clean document-style report, not a web page screenshot
30120 function buildDeltaPdfHtml() {
30121 var sd=_sd, dr=getDeltaExportRows();
30122 var dchg=dr.filter(function(r){return (r[2]||'')!=='unchanged';});
30123 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+'%';}
30124 function pcls(b,c){var v=Number(c)-Number(b);return v>0?'pos':(v<0?'neg':'zero');}
30125 var projEl=document.querySelector('[data-folder]'), proj=projEl?projEl.getAttribute('data-folder'):'';
30126 var projName=proj?(String(proj).replace(/[\\/]+$/,'').split(/[\\/]/).pop()||proj):proj;
30127 var tz;try{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){tz='America/Los_Angeles';}
30128 var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
30129 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
30130 function fmtN(n){return Number(n).toLocaleString();}
30131 function fullN(n){var v=Number(n);return isNaN(v)?'\u2014':v.toLocaleString();}
30132 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>';}
30133 var lm={};
30134 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;});
30135 var langs=Object.keys(lm).sort(function(a,b){return lm[b].c-lm[a].c;}).slice(0,15);
30136 var tfTotal=sd.fm+sd.fa+sd.fr+sd.fu;
30137 // The header/footer flow in normal document order (NOT position:fixed).
30138 // A fixed header repeats on every printed page in Chromium and overlaps
30139 // the content beneath it — silently swallowing the first few table rows of
30140 // pages 2+ and clipping the summary cards on page 1. Letting the header
30141 // flow once at the top and relying on the table's <thead> (which Chromium
30142 // repeats per page) keeps every row visible. `.body` keeps a small inset
30143 // so nothing bleeds to the sheet edge.
30144 var css='body{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}'+
30145 '.pdf-header{-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
30146 '.pdf-footer{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
30147 '.page-hdr{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}'+
30148 '.ph-brand{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}'+
30149 '.ph-brand em{color:#c45c10;font-style:normal;}'+
30150 '.ph-title{font-size:14px;font-weight:600;color:#555;}'+
30151 '.ph-date{font-size:11px;color:#888;text-align:right;white-space:nowrap;}'+
30152 '.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;}'+
30153 '.ib-name{font-size:13px;font-weight:800;color:#fff;}'+
30154 '.ib-path{font-size:10px;color:#8899aa;margin-top:2px;}'+
30155 '.ib-right{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}'+
30156 '.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;}'+
30157 '.body{padding:12px 18px 0;}'+
30158 '.sg{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}'+
30159 '.sc{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}'+
30160 '.sv{font-size:18px;font-weight:900;color:#c45c10;}'+
30161 '.sl{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}'+
30162 '.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;}'+
30163 '.meta>div{flex:1 1 0;}'+
30164 '.ml{color:#888;font-size:10px;text-transform:uppercase;letter-spacing:.06em;}.mv{font-weight:700;margin-top:3px;font-size:15px;}'+
30165 '.sec{margin-bottom:10px;}'+
30166 '.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;}'+
30167 '.pg-rhdr th{background:#0f1420;color:#fff;padding:0;border:none;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
30168 '.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;}'+
30169 '.pg-rhdr-in em{color:#c45c10;font-style:normal;}'+
30170 '.pg-rhdr-r{color:#9fb0c8;font-weight:600;text-transform:none;letter-spacing:0;}'+
30171 'table{width:100%;border-collapse:collapse;font-size:12px;}'+
30172 '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;}'+
30173 'td{border-bottom:1px solid #eee;padding:3px 8px;vertical-align:middle;}'+
30174 'tr:nth-child(even) td{background:#faf8f6;}'+
30175 '.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;}'+
30176 '.rfoot-spacer{height:30px!important;border:none!important;padding:0!important;background:#fff!important;}'+
30177 '.msec{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
30178 '.mcard{border:1px solid #ddd;border-radius:8px;padding:8px 11px;}'+
30179 '.mc-l{font-size:9px;font-weight:700;text-transform:uppercase;color:#888;letter-spacing:.05em;}'+
30180 '.mc-v{font-size:17px;font-weight:900;color:#1a2035;margin-top:3px;}'+
30181 '.mc-b{font-size:10px;color:#999;margin-top:2px;}'+
30182 '.mc-p{font-size:11px;font-weight:700;margin-top:2px;}'+
30183 '.mc-p.pos{color:#2a6846;}.mc-p.neg{color:#b23030;}.mc-p.zero{color:#999;}'+
30184 '.fcsec{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
30185 '.fcc{border:1px solid #e5e0d8;border-radius:8px;padding:8px 11px;display:flex;align-items:center;gap:9px;background:#faf8f6;}'+
30186 '.fcc-n{font-size:18px;font-weight:900;}'+
30187 '.fcc-l{font-size:10px;font-weight:600;color:#666;line-height:1.25;}';
30188 var fileRows=dchg.map(function(r){
30189 var st=r[2]||'',ss=st==='added'?'color:#2a6846;font-weight:700':st==='removed'?'color:#b23030;font-weight:700':'';
30190 return '<tr><td style="word-break:break-all">'+esc(r[0])+'</td><td>'+esc(r[1])+'</td>'+
30191 '<td style="'+ss+'">'+esc(st)+'</td>'+
30192 '<td style="text-align:right">'+fmtN(r[3])+'</td>'+
30193 '<td style="text-align:right">'+fmtN(r[4])+'</td>'+
30194 '<td style="text-align:right">'+delt(r[5])+'</td></tr>';
30195 }).join('')||'<tr><td colspan="6" style="text-align:center;color:#888;font-style:italic;padding:10px">No file changes between these scans.</td></tr>';
30196 var more='';
30197 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('');
30198 var extraCards='';
30199 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>';}
30200 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>';}
30201 return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta</title><style>'+css+'</style></head><body>'+
30202 '<div class="pdf-header">'+
30203 '<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>'+
30204 '<div class="info-bar"><div><div class="ib-name">'+esc(projName)+'</div><div class="ib-path">'+esc(proj)+'</div></div>'+
30205 '<div class="ib-right">Baseline: '+esc(_blabel)+'<br>Current: '+esc(_clabel)+'</div></div>'+
30206 '</div>'+
30207 '<div class="body">'+
30208 '<div class="sec"><p class="sh">Summary Metrics</p>'+
30209 '<div class="msec">'+
30210 '<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>'+
30211 '<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>'+
30212 '<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>'+
30213 '<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>'+
30214 '<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>'+
30215 '<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>'+
30216 extraCards+'</div></div>'+
30217 '<div class="sec"><p class="sh">File Changes</p>'+
30218 '<div class="fcsec">'+
30219 '<div class="fcc"><span class="fcc-n" style="color:#d4a017">'+fullN(sd.fm)+'</span><span class="fcc-l">Modified</span></div>'+
30220 '<div class="fcc"><span class="fcc-n" style="color:#2a6846">'+fullN(sd.fa)+'</span><span class="fcc-l">Added</span></div>'+
30221 '<div class="fcc"><span class="fcc-n" style="color:#b23030">'+fullN(sd.fr)+'</span><span class="fcc-l">Removed</span></div>'+
30222 '<div class="fcc"><span class="fcc-n" style="color:#555">'+fullN(sd.fu)+'</span><span class="fcc-l">Unchanged (identical code counts)</span></div>'+
30223 '<div class="fcc"><span class="fcc-n" style="color:#1a2035">'+fullN(Number(sd.fm)+Number(sd.fa)+Number(sd.fr)+Number(sd.fu))+'</span><span class="fcc-l">Total (modified + added + removed + unchanged)</span></div>'+
30224 '</div></div>'+
30225 (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>':'')+
30226 '<div class="sec">'+
30227 '<table><thead>'+
30228 '<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>'+
30229 '<tr><th>File</th><th>Language</th><th>Status</th>'+
30230 '<th style="text-align:right">Code Before</th><th style="text-align:right">Code After</th><th style="text-align:right">Code \u0394</th>'+
30231 '</tr></thead><tbody>'+fileRows+more+'</tbody><tfoot><tr><td colspan="6" class="rfoot-spacer"></td></tr></tfoot></table></div>'+
30232 '</div>'+
30233 '<div class="rfoot">'+
30234 '<span>oxide-sloc v{{ version }} | AGPL-3.0-or-later</span><span>Scan Delta Report</span>'+
30235 '<span>'+esc(sd.bid)+' → '+esc(sd.cid)+'</span>'+
30236 '</div>'+
30237 '</body></html>';
30238 }
30239 function doDeltaPdf(btn) {
30240 window.slocExportPdf({html:buildDeltaPdfHtml(),filename:getExportFilename('pdf'),button:btn});
30241 }
30242 var pdfBtn = document.getElementById('delta-pdf-btn');
30243 if (pdfBtn) pdfBtn.addEventListener('click', function() { doDeltaPdf(pdfBtn); });
30244 var pagePdfBtn = document.getElementById('page-export-pdf-btn');
30245 if (pagePdfBtn) pagePdfBtn.addEventListener('click', function() { doDeltaPdf(pagePdfBtn); });
30246 if (location.protocol === 'file:') {
30247 [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'; } });
30248 [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'; } });
30249 }
30250 var ppSel = document.getElementById('per-page-sel');
30251 if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
30252 var pathLink = document.getElementById('project-path-link');
30253 if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
30254 })();
30255
30256 // ── Export helpers ────────────────────────────────────────────────────────
30257 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
30258 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
30259 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);}
30260 function slocMakeXlsx(fname,sd,dr){
30261 var enc=new TextEncoder();
30262 // CRC-32 table
30263 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;}
30264 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;}
30265 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
30266 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
30267 // Shared string table
30268 var ss=[],si={};
30269 function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
30270 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
30271 // Worksheet builder — each WS() call gets its own row counter R
30272 function WS(){
30273 var R=0,buf=[];
30274 function cl(c){return String.fromCharCode(65+c);}
30275 function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
30276 '<v>'+S(v)+'</v></c>';}
30277 function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
30278 (st?' s="'+st+'"':'')+'>'+
30279 '<v>'+(+v)+'</v></c>';}
30280 function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
30281 function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
30282 '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
30283 '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
30284 '<sheetFormatPr defaultRowHeight="15"/>'+
30285 (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
30286 return{sc:sc,nc:nc,row:row,xml:xml};
30287 }
30288 // Language breakdown
30289 var lm={};
30290 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;});
30291 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
30292 var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
30293 // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
30294 function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
30295 function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
30296 function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
30297 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):'';}
30298 function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
30299 // Summary sheet
30300 var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
30301 r1(s1(0,'OxideSLOC \u2014 Scan Delta Report',1));
30302 r1(s1(0,proj,2));
30303 r1(s1(0,sd.bts+' \u2192 '+sd.cts,2));
30304 r1('');
30305 r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
30306 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))));
30307 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))));
30308 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))));
30309 r1('');
30310 r1(s1(0,'FILE CHANGES',8));
30311 r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
30312 r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
30313 r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
30314 r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
30315 r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
30316 r1(s1(0,'Total')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm+sd.fa+sd.fr+sd.fu,4)+s1(4,_tp(sd.fm+sd.fa+sd.fr+sd.fu)));
30317 if(langs.length){
30318 r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
30319 r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
30320 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)));});
30321 }
30322 r1('');r1(s1(0,'SCAN METADATA',8));
30323 r1(s1(1,_blabel)+s1(2,_clabel));
30324 r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
30325 r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
30326 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"/>');
30327 // File Delta sheet
30328 var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
30329 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));
30330 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)));});
30331 var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
30332 // Shared strings XML
30333 var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
30334 '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
30335 ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
30336 // XLSX file map
30337 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
30338 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>',
30339 '_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>',
30340 '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>',
30341 '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>',
30342 '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>',
30343 'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
30344 // ZIP packer — STORED (no compression), compatible with all XLSX readers
30345 var zparts=[],zcds=[],zoff=0,znf=0;
30346 ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
30347 'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
30348 ].forEach(function(name){
30349 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
30350 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]);
30351 var entry=new Uint8Array(lha.length+nb.length+sz);
30352 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
30353 zparts.push(entry);
30354 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));
30355 var cde=new Uint8Array(cda.length+nb.length);
30356 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
30357 zcds.push(cde);zoff+=entry.length;znf++;
30358 });
30359 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
30360 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]);
30361 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
30362 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
30363 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
30364 zout.set(new Uint8Array(ea),zpos);
30365 var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
30366 var xurl=URL.createObjectURL(xblob);
30367 var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
30368 document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
30369 setTimeout(function(){URL.revokeObjectURL(xurl);},200);
30370 }
30371 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;');}
30372 var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
30373 function getExportFilename(ext){return _exportBase+'.'+ext;}
30374
30375 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 }}'};
30376 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;}
30377 var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
30378 var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
30379 function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
30380 function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
30381 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):'';}
30382 var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
30383 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)]];}
30384 var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
30385 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)];});}
30386 window.exportDeltaCsv = function(){slocCsv(_exportBase+'.csv',_dh,getDeltaExportRows());};
30387 window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
30388
30389 // ── Chart HTML report ─────────────────────────────────────────────────────
30390 function slocChartReport(fname, sd, dr) {
30391 var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
30392 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
30393 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
30394 function fmt(n){return Number(n).toLocaleString();}
30395 function px(n){return Math.round(n);}
30396 var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
30397 // Language map
30398 var lm={};
30399 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;});
30400 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
30401
30402 // Builds onmouse* attrs for interactive tooltip on each SVG element
30403 function barTT(label,val){
30404 return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
30405 }
30406
30407 // ── Chart 1: Baseline vs Current grouped bars (height fills the card to
30408 // match the Language Code Delta column height) ────────────
30409 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'}];
30410 var FONT_C="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif";
30411 var C1W=600,c1mt=36,c1mb=30,c1ml=14,c1mr=14,c1bw=56,c1gap=10,C1H=380;
30412 var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length;
30413 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30414 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"/>';}
30415 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
30416 c1mets.forEach(function(m,i){
30417 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
30418 // Per-metric scale so small magnitudes (files) stay visible next to large ones (code).
30419 var gMax=Math.max(m.b,m.c)*1.15||1;
30420 var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
30421 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>';
30422 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))+'/>';
30423 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>';
30424 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))+'/>';
30425 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>';
30426 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>';
30427 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>';
30428 });
30429 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>';
30430 c1+='</svg>';
30431
30432 // ── Chart 2: Delta by Metric ─────────────────────────────────────────
30433 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'}];
30434 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
30435 var C2W=530,rH=56,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
30436 var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
30437 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30438 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30439 mets.forEach(function(m,i){
30440 var y=16+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
30441 var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
30442 var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
30443 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>';
30444 c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
30445 if(bw>=52){
30446 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>';
30447 }else{
30448 var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
30449 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>';
30450 }
30451 });
30452 c2+='</svg>';
30453
30454 // ── Chart 3: Language Code Delta ─────────────────────────────────────
30455 var c3='';
30456 if(langs.length){
30457 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
30458 var C3W=550,c3LW=124,c3FW=52;
30459 var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
30460 var L3rH=30,C3H=langs.length*L3rH+20;
30461 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30462 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30463 langs.forEach(function(l,i){
30464 var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
30465 var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
30466 var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
30467 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>';
30468 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':''))+'/>';
30469 if(bw>=48){
30470 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>';
30471 }else{
30472 var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
30473 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>';
30474 }
30475 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>';
30476 });
30477 c3+='</svg>';
30478 }
30479
30480 // ── Chart 4: File Change Donut — centered pie with legend below
30481 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;});
30482 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
30483 var C4W=240,Ro=75,Ri=48,cx4=120,cy4=88,legY=172,legRowH=18,C4H=legY+Math.ceil(segs.length/2)*legRowH+8;
30484 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">';
30485 var ang=-Math.PI/2;
30486 segs.forEach(function(s){
30487 var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
30488 var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
30489 var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
30490 var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
30491 var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
30492 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)+'%')+'/>';
30493 ang+=sw;
30494 });
30495 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>';
30496 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>';
30497 segs.forEach(function(s,i){
30498 var col=i%2===0?14:C4W/2+6,row=Math.floor(i/2);
30499 c4+='<rect x="'+col+'" y="'+(legY+row*legRowH)+'" width="12" height="12" fill="'+s.c+'" rx="2"/>';
30500 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>';
30501 });
30502 c4+='</svg>';
30503
30504 // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
30505 var ttJs='var tt=document.getElementById("ox-tt");'+
30506 'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
30507 'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
30508 'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
30509 'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
30510 'tt.style.left=x+"px";tt.style.top=y+"px";}'+
30511 'function oxHT(){tt.style.display="none";}';
30512
30513 // body max-width keeps charts from inflating beyond design dimensions on
30514 // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
30515 // each chart's height blows up proportionally, breaking the one-page layout.
30516 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;}'+
30517 'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
30518 '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
30519 'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
30520 '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
30521 '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
30522 'svg{display:block;}'+
30523 '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
30524 '#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;}'+
30525 '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
30526 var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
30527 '<title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
30528 '<div id="ox-tt"><\/div>'+
30529 '<h1>OxideSLOC — Scan Delta Charts<\/h1>'+
30530 '<p class="sub">'+esc(proj)+' · '+esc(sd.bts)+' → '+esc(sd.cts)+'<\/p>'+
30531 '<div class="two-col">'+
30532 '<div class="card"><h2>Code Metrics — Baseline vs Current<\/h2>'+
30533 '<div class="leg">'+
30534 '<span><span class="dot" style="background:#E3A876"><\/span><span style="color:#C45C10;font-weight:600">Code Lines<\/span><\/span>'+
30535 '<span><span class="dot" style="background:#9FC3AE"><\/span><span style="color:#2A6846;font-weight:600">Files<\/span><\/span>'+
30536 '<span><span class="dot" style="background:#E0C58A"><\/span><span style="color:#BE8A2E;font-weight:600">Comments<\/span><\/span>'+
30537 '<span style="font-size:10px;color:#888"> (faded = before)<\/span><\/div>'+c1+'<\/div>'+
30538 (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
30539 '<\/div>'+
30540 '<div class="two-col">'+
30541 '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
30542 '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
30543 '<\/div>'+
30544 '<script>'+ttJs+'<\/script>'+
30545 '<\/body><\/html>';
30546 slocDownload(html, fname, 'text/html;charset=utf-8;');
30547 }
30548 window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
30549 window.buildDeltaChartsHtml = function() {
30550 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
30551 var sd=_sd;
30552 var projEl=document.querySelector('[data-folder]');
30553 var proj=projEl?projEl.getAttribute('data-folder'):'';
30554 var c1h=document.getElementById('ic-c1')?document.getElementById('ic-c1').innerHTML:'';
30555 var c2h=document.getElementById('ic-c2')?document.getElementById('ic-c2').innerHTML:'';
30556 var c3h=document.getElementById('ic-c3')?document.getElementById('ic-c3').innerHTML:'';
30557 var c4h=document.getElementById('ic-c4')?document.getElementById('ic-c4').innerHTML:'';
30558 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";}';
30559 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);}';
30560 return '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
30561 '<div id="ox-tt"><\/div>'+
30562 '<h1>OxideSLOC \u2014 Scan Delta Charts<\/h1>'+
30563 '<p class="sub">'+esc(proj)+' · '+esc(sd.bts||'')+' \u2192 '+esc(sd.cts||'')+'<\/p>'+
30564 '<div class="two-col">'+
30565 '<div class="card"><h2>Code Metrics \u2014 Baseline vs Current<\/h2>'+
30566 '<div class="leg"><span><span class="dot" style="background:#E3A876"><\/span><span style="color:#C45C10;font-weight:600">Code Lines<\/span><\/span>'+
30567 '<span><span class="dot" style="background:#9FC3AE"><\/span><span style="color:#2A6846;font-weight:600">Files<\/span><\/span>'+
30568 '<span><span class="dot" style="background:#E0C58A"><\/span><span style="color:#BE8A2E;font-weight:600">Comments<\/span><\/span><\/div>'+c1h+'<\/div>'+
30569 (c3h?'<div class="card"><h2>Language Code Delta<\/h2>'+c3h+'<\/div>':'<div><\/div>')+
30570 '<\/div>'+
30571 '<div class="two-col">'+
30572 '<div class="card"><h2>Delta by Metric<\/h2>'+c2h+'<\/div>'+
30573 '<div class="card"><h2>File Change Distribution<\/h2>'+c4h+'<\/div>'+
30574 '<\/div>'+
30575 '<script>'+ttJs+'<\/script>'+
30576 '<\/body><\/html>';
30577 };
30578 // ── Inline delta charts ────────────────────────────────────────────────────
30579 var _icTT=document.getElementById('ic-tt');
30580 window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
30581 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';};
30582 window.icHT=function(){if(_icTT)_icTT.style.display='none';};
30583 window.addEventListener('blur',function(){window.icHT();});
30584 document.addEventListener('visibilitychange',function(){if(document.hidden)window.icHT();});
30585 (function(){
30586 // Theme-aware palette — matches the canonical scheme used by /test-metrics
30587 // charts so every page renders bars/text/grid with the same colours and
30588 // adapts to dark mode (see Design section in CLAUDE.md).
30589 var cs=getComputedStyle(document.body),dark=document.body.classList.contains('dark-theme');
30590 function cv(n,fb){var v=cs.getPropertyValue(n);return(v&&v.trim())||fb;}
30591 var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
30592 // Deeper shade of each metric hue for "before"/baseline bars — bold (not
30593 // washed) so the chart reads with the same weight as /test-metrics.
30594 var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
30595 var FADE=dark?'#524238':'#e6d0bf';
30596 var textCol=cv('--text','#43342d'),mutedCol=cv('--muted','#7b675b'),LGY=cv('--line','#e6d0bf'),axisCol=cv('--line-strong','#d8bfad'),surfCol=cv('--surface','#fbf7f2');
30597 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
30598 function fmt(n){return Number(n).toLocaleString();}
30599 function px(n){return Math.round(n);}
30600 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
30601 function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
30602 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);});}
30603 var dr=getDeltaExportRows(),sd=_sd,lm={};
30604 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;});
30605 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
30606 // Chart 1: Baseline vs Current grouped bars. Height grows to fill the card so
30607 // the bars are as tall as the (usually taller) Language Code Delta sibling that
30608 // shares the same grid row, instead of sitting short at the top.
30609 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}];
30610 function drawC1(){
30611 var C1W=600,C1H=188;
30612 var host=document.getElementById('ic-c1'),card=host?host.closest('.ic-card'):null;
30613 if(host&&card&&host.clientWidth>0){
30614 var avW=host.clientWidth;
30615 var availPx=(card.getBoundingClientRect().bottom-16)-host.getBoundingClientRect().top;
30616 var wantH=availPx*C1W/avW;
30617 if(wantH>C1H)C1H=wantH;
30618 }
30619 var c1mt=36,c1mb=44,c1ml=14,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=56,c1gap=10;
30620 var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30621 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"/>';}
30622 c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
30623 c1mets.forEach(function(m,i){
30624 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
30625 // Each metric scales to its OWN max so wildly different magnitudes (e.g. 4.5M
30626 // code lines vs 28K files) are all readable — a shared scale buries the small ones.
30627 var gMax=Math.max(m.b,m.c)*1.15||1;
30628 var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
30629 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>';
30630 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"/>';
30631 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>';
30632 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"/>';
30633 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>';
30634 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>';
30635 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>';
30636 });
30637 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>';
30638 c1+='</svg>';
30639 return c1;
30640 }
30641 var c1=drawC1();
30642 // Chart 2: Delta by Metric
30643 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}];
30644 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
30645 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;
30646 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30647 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30648 mets.forEach(function(m,i){
30649 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);
30650 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>';
30651 c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"/>';
30652 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>';}
30653 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>';}
30654 });
30655 c2+='</svg>';
30656 // Chart 3: Language Code Delta
30657 var c3='';
30658 if(langs.length){
30659 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
30660 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;
30661 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
30662 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
30663 langs.forEach(function(l,i){
30664 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);
30665 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>';
30666 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"/>';
30667 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>';}
30668 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>';}
30669 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>';
30670 });
30671 c3+='</svg>';
30672 }
30673 // Chart 4: File Change Donut — pie left, legend to the right (vertically centered)
30674 var FONT4='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
30675 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;});
30676 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
30677 var DW=395,DH=Math.max(200,segs.length*30+44),cx4=104,cy4=Math.round(DH/2),Ro=88,Ri=48;
30678 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);
30679 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;
30680 if(segs.length===1){
30681 var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
30682 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+'"/>';
30683 } else {
30684 // Give every visible slice a small minimum sweep, taken from the largest
30685 // slice. Without this a ~100% slice (e.g. all-Unchanged) spans a full 360°
30686 // arc whose start and end points coincide, so SVG renders nothing (blank).
30687 var TWO=2*Math.PI,minSw=0.06,raw=segs.map(function(s){return s.v/tot*TWO;}),maxIdx=0;
30688 for(var k=1;k<raw.length;k++){if(raw[k]>raw[maxIdx])maxIdx=k;}
30689 var deficit=0,sweeps=raw.map(function(rw,k){if(k!==maxIdx&&rw<minSw){deficit+=(minSw-rw);return minSw;}return rw;});
30690 sweeps[maxIdx]=Math.max(0.001,sweeps[maxIdx]-deficit);
30691 segs.forEach(function(s,si){
30692 var sw=Math.min(sweeps[si],TWO-0.06),a2=ang+sw;
30693 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);
30694 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);
30695 var pct=Math.round(s.v/tot*100);
30696 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"/>';
30697 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>';}
30698 ang+=sw;
30699 });
30700 }
30701 c4+='<text x="'+cx4+'" y="'+(cy4-7)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="21" font-weight="800" fill="'+textCol+'">'+fmt(tot)+'</text>';
30702 c4+='<text x="'+cx4+'" y="'+(cy4+14)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="11" fill="'+mutedCol+'">total files</text>';
30703 segs.forEach(function(s,i){
30704 var ly=legYStart+i*legSpacing,pct=Math.round(s.v/tot*100);
30705 c4+='<g'+btt(s.l,fmt(s.v)+' files \u2022 '+pct+'%')+' style="cursor:pointer;">';
30706 c4+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+legSpacing+'" fill="transparent"/>';
30707 c4+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+s.c+'"/>';
30708 c4+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT4+'" font-size="'+Math.min(13,legSpacing-3)+'" fill="'+textCol+'">'+esc(s.l)+'</text>';
30709 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>';
30710 c4+='</g>';
30711 });
30712 c4+='</svg>';
30713 // Inject the fixed-height siblings first so the grid row settles to the (taller)
30714 // Language Code Delta height, then draw Code Metrics (c1) to fill that height.
30715 var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
30716 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);}
30717 var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
30718 var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
30719 var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=drawC1();addTT(e1);}
30720
30721 // Compare Timeline chart (Baseline vs Current, 2 points)
30722 (function() {
30723 var activeCmpMetric='code';
30724 var cmpMetricLabel={code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'};
30725 function renderCmpTL(metric, targetSvg, targetH) {
30726 var svg=targetSvg||document.getElementById('cmp-tl-svg');if(!svg)return;
30727 var W=svg.getBoundingClientRect().width||800,H=targetH||280;
30728 svg.setAttribute('height',H);
30729 var pad={l:62,r:20,t:32,b:72};
30730 var dark=document.body.classList.contains('dark-theme');
30731 var cmpPts=[
30732 {v:{code:_sd.bc,files:_sd.bf,comments:_sd.bcm,tests:_sd.btests,cov:_sd.bcov},label:(_sd.bsha||'').substring(0,7)||'Base'},
30733 {v:{code:_sd.cc,files:_sd.cf,comments:_sd.ccm,tests:_sd.ctests,cov:_sd.ccov},label:(_sd.csha||'').substring(0,7)||'Curr'}
30734 ];
30735 var pts=cmpPts.map(function(p){var v=p.v[metric];return(v==null)?null:Number(v);});
30736 var valid=pts.filter(function(v){return v!=null;});
30737 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;}
30738 var minV=0,maxV=Math.max.apply(null,valid);
30739 if(maxV<=0){maxV=1;}else{maxV=maxV*1.08;}
30740 var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
30741 var cx0=pad.l,cx1=pad.l+plotW;
30742 var cy0=pts[0]!=null?pad.t+plotH-(pts[0]-minV)/(maxV-minV)*plotH:pad.t+plotH;
30743 var cy1=pts[1]!=null?pad.t+plotH-(pts[1]-minV)/(maxV-minV)*plotH:pad.t+plotH;
30744 var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
30745 var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
30746 var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
30747 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();}
30748 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
30749 var parts=[];
30750 parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
30751 for(var gi=0;gi<5;gi++){
30752 var gy=pad.t+plotH/4*gi,gv=maxV-(maxV-minV)/4*gi;
30753 parts.push('<line x1="'+pad.l+'" y1="'+gy.toFixed(1)+'" x2="'+(W-pad.r)+'" y2="'+gy.toFixed(1)+'" stroke="'+gridColor+'" stroke-width="1"/>');
30754 parts.push('<text x="'+(pad.l-6)+'" y="'+(gy+4).toFixed(1)+'" text-anchor="end" font-size="10" fill="'+textColor+'">'+fmtN(gv)+'</text>');
30755 }
30756 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+'"/>');
30757 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"/>');
30758 var dotPts=[{cx:cx0,cy:cy0,v:pts[0],lbl:cmpPts[0].label,anchor:'start',lbl2:'BASELINE'},
30759 {cx:cx1,cy:cy1,v:pts[1],lbl:cmpPts[1].label,anchor:'end',lbl2:'CURRENT'}];
30760 dotPts.forEach(function(pt){
30761 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>');
30762 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"/>');
30763 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>');
30764 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>');
30765 });
30766 parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escH(cmpMetricLabel[metric]||metric)+'</text>');
30767 svg.setAttribute('viewBox','0 0 '+W+' '+H);
30768 svg.innerHTML=parts.join('');
30769 // Hover: crosshair + tooltip (matches multi-scan timeline)
30770 var cmpTT=document.getElementById('ic-tt');
30771 svg.onmousemove=function(e){
30772 var rect=svg.getBoundingClientRect();
30773 var scaleX=W/rect.width;
30774 var mouseX=(e.clientX-rect.left)*scaleX;
30775 var nearest=-1,minDist=Infinity;
30776 var cxArr=[cx0,cx1];
30777 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;}}
30778 if(nearest<0)return;
30779 var nc=cxArr[nearest],ny=(nearest===0?cy0:cy1);
30780 var xhair=svg.querySelector('.cmp-xhair');
30781 if(!xhair){xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','cmp-xhair');svg.appendChild(xhair);}
30782 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"/>';
30783 if(!cmpTT)return;
30784 var clbl=cmpPts[nearest].label;
30785 var scanLbl=nearest===0?'Baseline':'Current';
30786 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>';
30787 var bx=rect.left+(nc/W*rect.width)+18;
30788 if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
30789 cmpTT.style.left=bx+'px';cmpTT.style.top=(e.clientY-38)+'px';cmpTT.style.display='block';
30790 };
30791 svg.onmouseleave=function(){
30792 var xhair=svg.querySelector('.cmp-xhair');if(xhair)xhair.innerHTML='';
30793 if(cmpTT)cmpTT.style.display='none';
30794 };
30795 }
30796 document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(btn){
30797 btn.addEventListener('click',function(){
30798 activeCmpMetric=this.dataset.cmpMetric;
30799 document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(b){b.classList.remove('active');});
30800 this.classList.add('active');
30801 renderCmpTL(activeCmpMetric);
30802 });
30803 });
30804 var ttgl=document.getElementById('theme-toggle');
30805 if(ttgl)ttgl.addEventListener('click',function(){setTimeout(function(){renderCmpTL(activeCmpMetric);if(window.__sdFvTL)renderCmpTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);},0);});
30806 if(typeof ResizeObserver!=='undefined'){
30807 var cmpSvg=document.getElementById('cmp-tl-svg');
30808 if(cmpSvg)new ResizeObserver(function(){renderCmpTL(activeCmpMetric);}).observe(cmpSvg);
30809 }
30810 // Expose the timeline renderer + current metric so the Full View modal can
30811 // re-draw it live (pixel-sized chart can't be snapshot-scaled like the bars).
30812 window.__sdRenderTL=function(m,svgEl,h){renderCmpTL(m,svgEl,h);};
30813 window.__sdGetMetric=function(){return activeCmpMetric;};
30814 renderCmpTL(activeCmpMetric);
30815 })();
30816
30817 // HTML legend hover -> highlight matching SVG bars within the SAME card only
30818 document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){
30819 var metric=leg.getAttribute('data-highlight');
30820 var parentCard=leg.closest('.ic-card');
30821 var chartEl=parentCard?parentCard.querySelector('[id]'):null;
30822 if(!chartEl)return;
30823 leg.addEventListener('mouseenter',function(){
30824 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){
30825 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';}
30826 else{x.style.opacity='0.28';}
30827 });
30828 });
30829 leg.addEventListener('mouseleave',function(){
30830 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){x.style.filter='';x.style.opacity='';});
30831 });
30832 });
30833
30834 // ── Full View: enlarge any chart in a modal (snapshots current SVG) ──────
30835 (function(){
30836 var ov=document.getElementById('ic-svg-modal-ov');
30837 var body=document.getElementById('ic-svg-modal-body');
30838 var ttl=document.getElementById('ic-svg-modal-title');
30839 var closeBtn=document.getElementById('ic-svg-modal-close');
30840 if(!ov||!body)return;
30841 function close(){
30842 ov.classList.remove('open');body.innerHTML='';
30843 if(window.__sdFvTL){if(window.__sdFvTL.ro)window.__sdFvTL.ro.disconnect();window.__sdFvTL=null;}
30844 var tt=document.getElementById('ic-tt');if(tt)tt.style.display='none';
30845 }
30846 function open(srcId,title){
30847 var src=document.getElementById(srcId);if(!src)return;
30848 if(ttl)ttl.textContent=title||'';
30849 // The Timeline is pixel-sized (viewBox locked to its render width), so a static
30850 // snapshot stretches and loses interactivity. Re-render it live into the modal at
30851 // full size instead — keeps proportions, animation, crosshair, tooltip and the
30852 // metric tabs working exactly like the inline chart.
30853 if(srcId==='cmp-tl-svg'&&window.__sdRenderTL){
30854 var curM=window.__sdGetMetric?window.__sdGetMetric():'code';
30855 var mets=[['code','Code Lines'],['files','Files'],['comments','Comments'],['tests','Tests'],['cov','Coverage']];
30856 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('');
30857 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>';
30858 var fvSvg=body.querySelector('#cmp-tl-fv-svg');
30859 window.__sdFvTL={svg:fvSvg,h:440,metric:curM,ro:null};
30860 ov.classList.add('open');
30861 requestAnimationFrame(function(){window.__sdRenderTL(window.__sdFvTL.metric,fvSvg,440);});
30862 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;}
30863 body.querySelectorAll('[data-fv-metric]').forEach(function(b){
30864 b.addEventListener('click',function(){
30865 if(!window.__sdFvTL)return;
30866 window.__sdFvTL.metric=this.getAttribute('data-fv-metric');
30867 body.querySelectorAll('[data-fv-metric]').forEach(function(x){x.classList.remove('active');});
30868 this.classList.add('active');
30869 window.__sdRenderTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);
30870 });
30871 });
30872 return;
30873 }
30874 var card=src.closest('.ic-card');
30875 var legHtml='';
30876 if(card){var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg" style="margin-bottom:14px;">'+leg.innerHTML+'</div>';}
30877 var inner=src.tagName.toLowerCase()==='svg'?src.outerHTML:src.innerHTML;
30878 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;}
30879 body.innerHTML=legHtml+inner;
30880 var svg=body.querySelector('svg');
30881 if(svg){svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}
30882 addTT(body);
30883 ov.classList.add('open');
30884 }
30885 document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){
30886 btn.addEventListener('click',function(){open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));});
30887 });
30888 if(closeBtn)closeBtn.addEventListener('click',close);
30889 ov.addEventListener('click',function(e){if(e.target===ov)close();});
30890 document.addEventListener('keydown',function(e){if(e.key==='Escape'&&ov.classList.contains('open'))close();});
30891 })();
30892
30893 document.querySelectorAll('.cmp-author-val').forEach(function(el){var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');});
30894 })();
30895 </script>
30896 {{ toast_assets|safe }}
30897 <script nonce="{{ csp_nonce }}">
30898 (function(){
30899 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'}];
30900 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);});}
30901 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
30902 function init(){
30903 var btn=document.getElementById('settings-btn');if(!btn)return;
30904 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
30905 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>';
30906 document.body.appendChild(m);
30907 var g=document.getElementById('scheme-grid');
30908 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);});
30909 var cl=document.getElementById('settings-close');
30910 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);});})();
30911 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');});
30912 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
30913 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
30914 }
30915 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
30916 }());
30917 </script>
30918 <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]';
30919 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;}
30920 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>
30921</body>
30922</html>
30923"##,
30924 ext = "html"
30925)]
30926#[allow(clippy::struct_excessive_bools)]
30928struct CompareTemplate {
30929 loading_overlay: String,
30931 version: &'static str,
30932 project_label: String,
30933 baseline_git_commit: String,
30934 current_git_commit: String,
30935 baseline_run_id: String,
30936 current_run_id: String,
30937 baseline_run_id_short: String,
30938 current_run_id_short: String,
30939 baseline_timestamp: String,
30940 baseline_timestamp_utc_ms: i64,
30941 current_timestamp: String,
30942 current_timestamp_utc_ms: i64,
30943 project_path: String,
30944 baseline_code: u64,
30945 current_code: u64,
30946 code_lines_delta_str: String,
30947 code_lines_delta_class: String,
30948 baseline_files: u64,
30949 current_files: u64,
30950 files_analyzed_delta_str: String,
30951 files_analyzed_delta_class: String,
30952 baseline_comments: u64,
30953 current_comments: u64,
30954 comment_lines_delta_str: String,
30955 comment_lines_delta_class: String,
30956 baseline_code_fmt: String,
30957 current_code_fmt: String,
30958 baseline_files_fmt: String,
30959 current_files_fmt: String,
30960 baseline_comments_fmt: String,
30961 current_comments_fmt: String,
30962 code_lines_pct_str: String,
30963 files_analyzed_pct_str: String,
30964 comment_lines_pct_str: String,
30965 code_lines_added: i64,
30966 code_lines_removed: i64,
30967 code_lines_modified: i64,
30969 code_lines_unmodified: i64,
30971 code_lines_total: i64,
30973 new_scope: bool,
30975 churn_rate_str: String,
30976 churn_rate_class: String,
30977 scope_flag: bool,
30978 files_added: usize,
30979 files_removed: usize,
30980 files_modified: usize,
30981 files_unchanged: usize,
30982 files_total: usize,
30983 file_rows: Vec<CompareFileDeltaRow>,
30984 baseline_git_author: Option<String>,
30985 current_git_author: Option<String>,
30986 baseline_git_branch: String,
30987 current_git_branch: String,
30988 baseline_git_tags: Option<String>,
30989 current_git_tags: Option<String>,
30990 baseline_git_commit_date: Option<String>,
30991 current_git_commit_date: Option<String>,
30992 project_name: String,
30993 submodule_options: Vec<String>,
30995 has_any_submodule_data: bool,
30997 active_submodule: Option<String>,
30999 super_scope_active: bool,
31001 csp_nonce: String,
31002 toast_assets: String,
31004 coverage_delta_card: String,
31006 baseline_test_count: u64,
31007 current_test_count: u64,
31008 baseline_coverage_pct: Option<f64>,
31009 current_coverage_pct: Option<f64>,
31010}
31011
31012#[derive(Template)]
31015#[template(
31016 source = r##"
31017<!doctype html>
31018<html lang="en">
31019<head>
31020 <meta charset="utf-8">
31021 <meta name="viewport" content="width=device-width, initial-scale=1">
31022 <title>OxideSLOC | Sign In</title>
31023 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
31024 <style nonce="{{ csp_nonce }}">
31025 :root {
31026 --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
31027 --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
31028 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
31029 --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
31030 }
31031 *{box-sizing:border-box;}
31032 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);}
31033 .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);}
31034 .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
31035 .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
31036 .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
31037 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31038 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
31039 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31040 .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;}
31041 @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));}}
31042 .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
31043 .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
31044 h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
31045 .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
31046 .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;}
31047 label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
31048 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;}
31049 input[type=password]:focus{border-color:var(--oxide);}
31050 .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;}
31051 .btn:hover{opacity:.88;}
31052 .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
31053 code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
31054 </style>
31055</head>
31056<body>
31057 <div class="background-watermarks" aria-hidden="true">
31058 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31059 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31060 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31061 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31062 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31063 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31064 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31065 </div>
31066 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
31067<nav class="top-nav">
31068 <a class="brand" href="/">
31069 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
31070 <span class="brand-title">OxideSLOC</span>
31071 </a>
31072</nav>
31073<main class="page">
31074 <div class="card">
31075 <h1>Sign In</h1>
31076 <p class="subtitle">Enter the API key printed when the server started.</p>
31077 {% if has_error %}
31078 <div class="error">Incorrect API key — please try again.</div>
31079 {% endif %}
31080 <form method="POST" action="/auth/login">
31081 <input type="hidden" name="next" value="{{ next_url|e }}">
31082 <label for="key">API Key</label>
31083 <input id="key" type="password" name="key" autocomplete="current-password"
31084 placeholder="Paste your API key here" autofocus>
31085 <button type="submit" class="btn">Sign In</button>
31086 </form>
31087 <p class="hint">
31088 The API key was printed in the terminal when the server started.<br>
31089 To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
31090 Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
31091 </p>
31092 </div>
31093</main>
31094<script nonce="{{ csp_nonce }}">
31095(function() {
31096 (function randomizeWatermarks() {
31097 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
31098 if (!wms.length) return;
31099 var placed = [];
31100 function tooClose(top, left) {
31101 for (var i = 0; i < placed.length; i++) {
31102 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
31103 if (dt < 16 && dl < 12) return true;
31104 }
31105 return false;
31106 }
31107 function pick(leftBand) {
31108 for (var attempt = 0; attempt < 50; attempt++) {
31109 var top = Math.random() * 88 + 2;
31110 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
31111 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
31112 }
31113 var top = Math.random() * 88 + 2;
31114 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
31115 placed.push([top, left]); return [top, left];
31116 }
31117 var half = Math.floor(wms.length / 2);
31118 wms.forEach(function (img, i) {
31119 var pos = pick(i < half);
31120 var size = Math.floor(Math.random() * 100 + 120);
31121 var rot = (Math.random() * 360).toFixed(1);
31122 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
31123 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;
31124 });
31125 })();
31126 (function spawnCodeParticles() {
31127 var container = document.getElementById('code-particles');
31128 if (!container) return;
31129 var snippets = [
31130 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
31131 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
31132 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
31133 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
31134 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
31135 ];
31136 var count = 38;
31137 for (var i = 0; i < count; i++) {
31138 (function(idx) {
31139 var el = document.createElement('span');
31140 el.className = 'code-particle';
31141 el.textContent = snippets[idx % snippets.length];
31142 var left = Math.random() * 94 + 2;
31143 var top = Math.random() * 88 + 6;
31144 var dur = (Math.random() * 10 + 9).toFixed(1);
31145 var delay = (Math.random() * 18).toFixed(1);
31146 var rot = (Math.random() * 26 - 13).toFixed(1);
31147 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
31148 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
31149 container.appendChild(el);
31150 })(i);
31151 }
31152 })();
31153})();
31154</script>
31155</body>
31156</html>
31157"##,
31158 ext = "html"
31159)]
31160pub(crate) struct LoginTemplate {
31161 pub(crate) csp_nonce: String,
31162 pub(crate) has_error: bool,
31163 pub(crate) next_url: String,
31164 pub(crate) lockout_threshold: u32,
31165}
31166
31167#[derive(Template)]
31170#[template(
31171 source = r##"
31172<!doctype html>
31173<html lang="en">
31174<head>
31175 <meta charset="utf-8">
31176 <meta name="viewport" content="width=device-width, initial-scale=1">
31177 <title>OxideSLOC — REST API Reference</title>
31178 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
31179 <style nonce="{{ csp_nonce }}">
31180 :root {
31181 --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
31182 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
31183 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
31184 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
31185 --success:#16a34a;
31186 }
31187 body.dark-theme {
31188 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
31189 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
31190 }
31191 *{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;}
31192 .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);}
31193 .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;}
31194 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
31195 .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));}
31196 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
31197 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
31198 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
31199 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
31200 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
31201 @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; } }
31202 .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;}
31203 a.nav-pill:hover{background:rgba(255,255,255,0.18);}
31204 .nav-pill.active{background:rgba(255,255,255,0.22);}
31205 .nav-dropdown{position:relative;display:inline-flex;}
31206 .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;}
31207 .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
31208 .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;}
31209 .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;}
31210 .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);}
31211 .nav-dropdown-menu a:last-child{border-bottom:none;}
31212 .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
31213 .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
31214 .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;}
31215 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
31216 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
31217 .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;}
31218 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
31219 .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);}
31220 .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
31221 .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
31222 .settings-modal-body{padding:14px 16px 16px;}
31223 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
31224 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
31225 .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;}
31226 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
31227 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
31228 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
31229 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
31230 .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;}
31231 .tz-select:focus{border-color:var(--oxide);}
31232 .page{max-width:960px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
31233 .page-header{margin-bottom:28px;}
31234 .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
31235 .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
31236 .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;}
31237 .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
31238 .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
31239 .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
31240 .callout strong{font-weight:800;}
31241 .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;}
31242 body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
31243 .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;}
31244 .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
31245 .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;}
31246 body.dark-theme .base-url-value{color:var(--accent);}
31247 .section{margin-bottom:36px;}
31248 .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);}
31249 .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
31250 .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
31251 .ep-header:hover{background:var(--surface-2);}
31252 .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;}
31253 .method.get{background:#dcfce7;color:#166534;}
31254 .method.post{background:#dbeafe;color:#1e40af;}
31255 .method.delete{background:#fee2e2;color:#991b1b;}
31256 body.dark-theme .method.get{background:#14532d;color:#86efac;}
31257 body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
31258 body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
31259 .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
31260 .ep-path .param{color:var(--oxide-2);}
31261 body.dark-theme .ep-path .param{color:var(--oxide);}
31262 .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;}
31263 .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
31264 .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
31265 .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
31266 body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
31267 body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
31268 body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
31269 .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
31270 .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
31271 .ep-card.open .chevron{transform:rotate(180deg);}
31272 .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
31273 .ep-card.open .ep-body{display:block;}
31274 .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
31275 .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;}
31276 .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
31277 body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
31278 .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
31279 table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
31280 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);}
31281 table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
31282 table.params tr:last-child td{border-bottom:none;}
31283 .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
31284 .pt-type{color:var(--muted-2);font-size:12px;}
31285 .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;}
31286 .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;}
31287 body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
31288 body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
31289 details.schema{margin-bottom:14px;}
31290 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;}
31291 details.schema summary:hover{color:var(--text);}
31292 .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;}
31293 .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
31294 .curl-wrap{position:relative;}
31295 .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;}
31296 .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;}
31297 .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
31298 .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
31299 .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
31300 .webhook-note a{color:var(--accent-2);text-decoration:none;}
31301 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31302 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
31303 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31304 .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;}
31305 @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));}}
31306 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
31307 .site-footer a{color:var(--muted);}
31308 </style>
31309</head>
31310<body>
31311 <div class="background-watermarks" aria-hidden="true">
31312 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31313 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31314 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31315 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31316 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31317 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31318 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31319 </div>
31320 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
31321 <div class="top-nav">
31322 <div class="top-nav-inner">
31323 <a class="brand" href="/">
31324 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
31325 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
31326 </a>
31327 <div class="nav-right">
31328 <a class="nav-pill" href="/">Home</a>
31329 <div class="nav-dropdown">
31330 <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>
31331 <div class="nav-dropdown-menu">
31332 <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>
31333 </div>
31334 </div>
31335 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
31336 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
31337 <div class="nav-dropdown">
31338 <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>
31339 <div class="nav-dropdown-menu">
31340 <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>
31341 </div>
31342 </div>
31343 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
31344 <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>
31345 </button>
31346 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
31347 <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>
31348 <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>
31349 </button>
31350 </div>
31351 </div>
31352 </div>
31353
31354 <div class="page">
31355 <div class="page-header">
31356 <h1 class="page-title">REST API Reference</h1>
31357 <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>
31358 </div>
31359
31360 {% if has_api_key %}
31361 <div class="callout key-set">
31362 <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>
31363 <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>
31364 </div>
31365 {% else %}
31366 <div class="callout no-key">
31367 <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>
31368 <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>
31369 </div>
31370 {% endif %}
31371
31372 <div class="base-url-bar">
31373 <span class="base-url-label">Base URL</span>
31374 <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
31375 </div>
31376
31377 <!-- Health -->
31378 <div class="section">
31379 <h2 class="section-title">Health & Status</h2>
31380 <div class="ep-card">
31381 <div class="ep-header">
31382 <span class="method get">GET</span>
31383 <span class="ep-path">/healthz</span>
31384 <span class="auth-badge public">Public</span>
31385 <span class="ep-desc">Server liveness check</span>
31386 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31387 </div>
31388 <div class="ep-body">
31389 <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>
31390 <p class="params-heading">Response</p>
31391 <div class="schema-block">200 OK
31392Content-Type: text/plain
31393
31394ok</div>
31395 <p class="curl-heading">Example</p>
31396 <div class="curl-wrap">
31397 <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
31398 <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
31399 </div>
31400 </div>
31401 </div>
31402 </div>
31403
31404 <!-- Badges -->
31405 <div class="section">
31406 <h2 class="section-title">Badges</h2>
31407 <div class="ep-card">
31408 <div class="ep-header">
31409 <span class="method get">GET</span>
31410 <span class="ep-path">/badge/<span class="param">{metric}</span></span>
31411 <span class="auth-badge public">Public</span>
31412 <span class="ep-desc">SVG badge for README / dashboard embedding</span>
31413 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31414 </div>
31415 <div class="ep-body">
31416 <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
31417 <p class="params-heading">Path Parameters</p>
31418 <table class="params">
31419 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31420 <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>
31421 </table>
31422 <p class="curl-heading">Example</p>
31423 <div class="curl-wrap">
31424 <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>
31425 <button class="curl-copy-btn" data-target="c-badge">Copy</button>
31426 </div>
31427 </div>
31428 </div>
31429 </div>
31430
31431 <!-- Metrics -->
31432 <div class="section">
31433 <h2 class="section-title">Metrics</h2>
31434
31435 <div class="ep-card">
31436 <div class="ep-header">
31437 <span class="method get">GET</span>
31438 <span class="ep-path">/api/metrics/latest</span>
31439 <span class="auth-badge protected">Protected</span>
31440 <span class="ep-desc">Latest scan metrics (JSON)</span>
31441 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31442 </div>
31443 <div class="ep-body">
31444 <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
31445 <details class="schema"><summary>Response schema</summary>
31446<div class="schema-block">{
31447 "run_id": string, // UUID
31448 "timestamp": string, // ISO-8601 UTC
31449 "project": string, // scanned root path
31450 "summary": {
31451 "files_analyzed": number,
31452 "files_skipped": number,
31453 "code_lines": number,
31454 "comment_lines": number,
31455 "blank_lines": number,
31456 "total_physical_lines": number,
31457 "functions": number,
31458 "classes": number,
31459 "variables": number,
31460 "imports": number
31461 },
31462 "languages": [
31463 { "name": string, "files": number, "code_lines": number,
31464 "comment_lines": number, "blank_lines": number,
31465 "functions": number, "classes": number,
31466 "variables": number, "imports": number }
31467 ]
31468}</div></details>
31469 <p class="curl-heading">Example</p>
31470 <div class="curl-wrap">
31471 <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31472 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
31473 <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
31474 </div>
31475 </div>
31476 </div>
31477
31478 <div class="ep-card">
31479 <div class="ep-header">
31480 <span class="method get">GET</span>
31481 <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
31482 <span class="auth-badge protected">Protected</span>
31483 <span class="ep-desc">Metrics for a specific run</span>
31484 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31485 </div>
31486 <div class="ep-body">
31487 <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
31488 <p class="params-heading">Path Parameters</p>
31489 <table class="params">
31490 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31491 <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>
31492 </table>
31493 <p class="curl-heading">Example</p>
31494 <div class="curl-wrap">
31495 <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31496 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/<run_id></pre>
31497 <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
31498 </div>
31499 </div>
31500 </div>
31501
31502 <div class="ep-card">
31503 <div class="ep-header">
31504 <span class="method get">GET</span>
31505 <span class="ep-path">/api/metrics/history</span>
31506 <span class="auth-badge protected">Protected</span>
31507 <span class="ep-desc">Paginated scan history</span>
31508 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31509 </div>
31510 <div class="ep-body">
31511 <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
31512 <p class="params-heading">Query Parameters</p>
31513 <table class="params">
31514 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31515 <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>
31516 <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>
31517 </table>
31518 <details class="schema"><summary>Response schema</summary>
31519<div class="schema-block">[{
31520 "run_id": string,
31521 "timestamp": string, // ISO-8601 UTC
31522 "commit": string | null,
31523 "branch": string | null,
31524 "tags": string[],
31525 "code_lines": number,
31526 "comment_lines": number,
31527 "blank_lines": number,
31528 "physical_lines": number,
31529 "files_analyzed": number,
31530 "project_label": string,
31531 "html_url": string | null
31532}]</div></details>
31533 <p class="curl-heading">Example</p>
31534 <div class="curl-wrap">
31535 <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31536 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
31537 <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
31538 </div>
31539 </div>
31540 </div>
31541
31542 <div class="ep-card">
31543 <div class="ep-header">
31544 <span class="method get">GET</span>
31545 <span class="ep-path">/api/project-history</span>
31546 <span class="auth-badge protected">Protected</span>
31547 <span class="ep-desc">Project-level scan summary</span>
31548 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31549 </div>
31550 <div class="ep-body">
31551 <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>
31552 <p class="params-heading">Query Parameters</p>
31553 <table class="params">
31554 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31555 <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>
31556 </table>
31557 <details class="schema"><summary>Response schema</summary>
31558<div class="schema-block">{
31559 "scan_count": number,
31560 "last_scan_id": string | null,
31561 "last_scan_timestamp": string | null, // ISO-8601
31562 "last_scan_code_lines": number | null,
31563 "last_git_branch": string | null,
31564 "last_git_commit": string | null
31565}</div></details>
31566 <p class="curl-heading">Example</p>
31567 <div class="curl-wrap">
31568 <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31569 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
31570 <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
31571 </div>
31572 </div>
31573 </div>
31574
31575 <div class="ep-card">
31576 <div class="ep-header">
31577 <span class="method get">GET</span>
31578 <span class="ep-path">/api/metrics/submodules</span>
31579 <span class="auth-badge protected">Protected</span>
31580 <span class="ep-desc">List known git submodules across scans</span>
31581 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31582 </div>
31583 <div class="ep-body">
31584 <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>
31585 <p class="params-heading">Query Parameters</p>
31586 <table class="params">
31587 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31588 <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>
31589 </table>
31590 <details class="schema"><summary>Response schema</summary>
31591<div class="schema-block">[{
31592 "name": string, // submodule name
31593 "relative_path": string // path relative to the project root
31594}]</div></details>
31595 <p class="curl-heading">Example</p>
31596 <div class="curl-wrap">
31597 <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31598 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
31599 <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
31600 </div>
31601 </div>
31602 </div>
31603 </div>
31604
31605 <!-- Async Run Status -->
31606 <div class="section">
31607 <h2 class="section-title">Async Run Status</h2>
31608
31609 <div class="ep-card">
31610 <div class="ep-header">
31611 <span class="method get">GET</span>
31612 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
31613 <span class="auth-badge protected">Protected</span>
31614 <span class="ep-desc">Poll scan completion</span>
31615 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31616 </div>
31617 <div class="ep-body">
31618 <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
31619 <details class="schema"><summary>Response schema</summary>
31620<div class="schema-block">// Running
31621{ "state": "running", "elapsed_secs": number }
31622
31623// Complete
31624{ "state": "complete", "run_id": string }
31625
31626// Failed
31627{ "state": "failed", "message": string }</div></details>
31628 <p class="curl-heading">Example</p>
31629 <div class="curl-wrap">
31630 <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31631 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/status</pre>
31632 <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
31633 </div>
31634 </div>
31635 </div>
31636
31637 <div class="ep-card">
31638 <div class="ep-header">
31639 <span class="method get">GET</span>
31640 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
31641 <span class="auth-badge protected">Protected</span>
31642 <span class="ep-desc">Poll PDF generation readiness</span>
31643 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31644 </div>
31645 <div class="ep-body">
31646 <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
31647 <details class="schema"><summary>Response schema</summary>
31648<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
31649 <p class="curl-heading">Example</p>
31650 <div class="curl-wrap">
31651 <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31652 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/pdf-status</pre>
31653 <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
31654 </div>
31655 </div>
31656 </div>
31657
31658 <div class="ep-card">
31659 <div class="ep-header">
31660 <span class="method post">POST</span>
31661 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
31662 <span class="auth-badge protected">Protected</span>
31663 <span class="ep-desc">Cancel a running scan</span>
31664 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31665 </div>
31666 <div class="ep-body">
31667 <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>
31668 <p class="curl-heading">Example</p>
31669 <div class="curl-wrap">
31670 <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
31671 -H "Authorization: Bearer $SLOC_API_KEY" \
31672 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/cancel</pre>
31673 <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
31674 </div>
31675 </div>
31676 </div>
31677 </div>
31678
31679 <!-- Run Management -->
31680 <div class="section">
31681 <h2 class="section-title">Run Management</h2>
31682
31683 <div class="ep-card">
31684 <div class="ep-header">
31685 <span class="method get">GET</span>
31686 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/bundle</span>
31687 <span class="auth-badge protected">Protected</span>
31688 <span class="ep-desc">Download all artifacts for a run as a ZIP archive</span>
31689 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31690 </div>
31691 <div class="ep-body">
31692 <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>
31693 <p class="params-heading">Path Parameters</p>
31694 <table class="params">
31695 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31696 <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>
31697 </table>
31698 <details class="schema"><summary>Response</summary>
31699<div class="schema-block">200 OK — Content-Type: application/zip
31700Content-Disposition: attachment; filename="sloc-run-<run_id>.zip"
31701
31702404 Not Found — { "error": string } (run not found or no artifacts)</div></details>
31703 <p class="curl-heading">Example</p>
31704 <div class="curl-wrap">
31705 <pre class="curl-block" data-curl-id="c-run-bundle">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31706 -o run.zip \
31707 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/bundle</pre>
31708 <button class="curl-copy-btn" data-target="c-run-bundle">Copy</button>
31709 </div>
31710 </div>
31711 </div>
31712
31713 <div class="ep-card">
31714 <div class="ep-header">
31715 <span class="method delete">DELETE</span>
31716 <span class="ep-path">/api/runs/<span class="param">{run_id}</span></span>
31717 <span class="auth-badge protected">Protected</span>
31718 <span class="ep-desc">Permanently delete a run and all its artifacts</span>
31719 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31720 </div>
31721 <div class="ep-body">
31722 <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>
31723 <p class="params-heading">Path Parameters</p>
31724 <table class="params">
31725 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31726 <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>
31727 </table>
31728 <details class="schema"><summary>Response</summary>
31729<div class="schema-block">204 No Content — run successfully deleted
31730
31731500 Internal Server Error — { "error": string } (filesystem deletion failed)</div></details>
31732 <p class="curl-heading">Example</p>
31733 <div class="curl-wrap">
31734 <pre class="curl-block" data-curl-id="c-run-delete">curl -X DELETE \
31735 -H "Authorization: Bearer $SLOC_API_KEY" \
31736 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id></pre>
31737 <button class="curl-copy-btn" data-target="c-run-delete">Copy</button>
31738 </div>
31739 </div>
31740 </div>
31741
31742 <div class="ep-card">
31743 <div class="ep-header">
31744 <span class="method post">POST</span>
31745 <span class="ep-path">/api/runs/cleanup</span>
31746 <span class="auth-badge protected">Protected</span>
31747 <span class="ep-desc">Bulk delete runs older than N days</span>
31748 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31749 </div>
31750 <div class="ep-body">
31751 <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>
31752 <p class="params-heading">Request Body (application/json)</p>
31753 <table class="params">
31754 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31755 <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>
31756 </table>
31757 <details class="schema"><summary>Response schema</summary>
31758<div class="schema-block">{ "deleted": number } // count of runs removed</div></details>
31759 <p class="curl-heading">Example — delete runs older than 60 days</p>
31760 <div class="curl-wrap">
31761 <pre class="curl-block" data-curl-id="c-runs-cleanup">curl -X POST \
31762 -H "Authorization: Bearer $SLOC_API_KEY" \
31763 -H "Content-Type: application/json" \
31764 -d '{"older_than_days":60}' \
31765 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/cleanup</pre>
31766 <button class="curl-copy-btn" data-target="c-runs-cleanup">Copy</button>
31767 </div>
31768 </div>
31769 </div>
31770 </div>
31771
31772 <!-- Retention Policy -->
31773 <div class="section">
31774 <h2 class="section-title">Retention Policy</h2>
31775
31776 <div class="ep-card">
31777 <div class="ep-header">
31778 <span class="method get">GET</span>
31779 <span class="ep-path">/api/cleanup-policy</span>
31780 <span class="auth-badge protected">Protected</span>
31781 <span class="ep-desc">Get the current retention policy and last-run metadata</span>
31782 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31783 </div>
31784 <div class="ep-body">
31785 <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>
31786 <details class="schema"><summary>Response schema</summary>
31787<div class="schema-block">{
31788 "policy": {
31789 "enabled": boolean,
31790 "max_age_days": number | null, // delete runs older than N days
31791 "max_run_count": number | null, // keep only the N most recent runs
31792 "interval_hours": number // hours between background passes
31793 } | null,
31794 "last_run_at": string | null, // ISO-8601 UTC timestamp
31795 "last_run_deleted": number | null // runs deleted in last pass
31796}</div></details>
31797 <p class="curl-heading">Example</p>
31798 <div class="curl-wrap">
31799 <pre class="curl-block" data-curl-id="c-policy-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31800 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
31801 <button class="curl-copy-btn" data-target="c-policy-get">Copy</button>
31802 </div>
31803 </div>
31804 </div>
31805
31806 <div class="ep-card">
31807 <div class="ep-header">
31808 <span class="method post">POST</span>
31809 <span class="ep-path">/api/cleanup-policy</span>
31810 <span class="auth-badge protected">Protected</span>
31811 <span class="ep-desc">Save or update the retention policy</span>
31812 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31813 </div>
31814 <div class="ep-body">
31815 <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>
31816 <p class="params-heading">Request Body (application/json)</p>
31817 <table class="params">
31818 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31819 <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>
31820 <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>
31821 <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>
31822 <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>
31823 </table>
31824 <details class="schema"><summary>Response</summary>
31825<div class="schema-block">204 No Content — policy saved and task (re)started
31826
31827500 Internal Server Error — { "error": string }</div></details>
31828 <p class="curl-heading">Example — keep 30 days, max 100 runs, check daily</p>
31829 <div class="curl-wrap">
31830 <pre class="curl-block" data-curl-id="c-policy-post">curl -X POST \
31831 -H "Authorization: Bearer $SLOC_API_KEY" \
31832 -H "Content-Type: application/json" \
31833 -d '{"enabled":true,"max_age_days":30,"max_run_count":100,"interval_hours":24}' \
31834 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
31835 <button class="curl-copy-btn" data-target="c-policy-post">Copy</button>
31836 </div>
31837 </div>
31838 </div>
31839
31840 <div class="ep-card">
31841 <div class="ep-header">
31842 <span class="method post">POST</span>
31843 <span class="ep-path">/api/cleanup-policy/run-now</span>
31844 <span class="auth-badge protected">Protected</span>
31845 <span class="ep-desc">Trigger an immediate cleanup pass</span>
31846 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31847 </div>
31848 <div class="ep-body">
31849 <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>
31850 <details class="schema"><summary>Response schema</summary>
31851<div class="schema-block">{ "deleted": number } // count of runs removed in this pass</div></details>
31852 <p class="curl-heading">Example</p>
31853 <div class="curl-wrap">
31854 <pre class="curl-block" data-curl-id="c-policy-run-now">curl -X POST \
31855 -H "Authorization: Bearer $SLOC_API_KEY" \
31856 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy/run-now</pre>
31857 <button class="curl-copy-btn" data-target="c-policy-run-now">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/cleanup-policy</span>
31866 <span class="auth-badge protected">Protected</span>
31867 <span class="ep-desc">Remove the retention policy and stop the background task</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">Clears the saved retention policy and stops the background cleanup task if it is running. Does not delete any existing scan runs.</p>
31872 <details class="schema"><summary>Response</summary>
31873<div class="schema-block">204 No Content — policy removed and task stopped</div></details>
31874 <p class="curl-heading">Example</p>
31875 <div class="curl-wrap">
31876 <pre class="curl-block" data-curl-id="c-policy-delete">curl -X DELETE \
31877 -H "Authorization: Bearer $SLOC_API_KEY" \
31878 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
31879 <button class="curl-copy-btn" data-target="c-policy-delete">Copy</button>
31880 </div>
31881 </div>
31882 </div>
31883 </div>
31884
31885 <!-- Scan Profiles -->
31886 <div class="section">
31887 <h2 class="section-title">Scan Profiles</h2>
31888
31889 <div class="ep-card">
31890 <div class="ep-header">
31891 <span class="method get">GET</span>
31892 <span class="ep-path">/api/scan-profiles</span>
31893 <span class="auth-badge protected">Protected</span>
31894 <span class="ep-desc">List saved scan profiles</span>
31895 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31896 </div>
31897 <div class="ep-body">
31898 <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
31899 <details class="schema"><summary>Response schema</summary>
31900<div class="schema-block">{
31901 "profiles": [{
31902 "id": string, // UUID
31903 "name": string,
31904 "created_at": string, // ISO-8601
31905 "params": object
31906 }]
31907}</div></details>
31908 <p class="curl-heading">Example</p>
31909 <div class="curl-wrap">
31910 <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31911 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
31912 <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
31913 </div>
31914 </div>
31915 </div>
31916
31917 <div class="ep-card">
31918 <div class="ep-header">
31919 <span class="method post">POST</span>
31920 <span class="ep-path">/api/scan-profiles</span>
31921 <span class="auth-badge protected">Protected</span>
31922 <span class="ep-desc">Save a scan profile</span>
31923 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31924 </div>
31925 <div class="ep-body">
31926 <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
31927 <p class="params-heading">Request Body (application/json)</p>
31928 <table class="params">
31929 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
31930 <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>
31931 <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>
31932 </table>
31933 <details class="schema"><summary>Response schema</summary>
31934<div class="schema-block">{ "ok": true }</div></details>
31935 <p class="curl-heading">Example</p>
31936 <div class="curl-wrap">
31937 <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
31938 -H "Authorization: Bearer $SLOC_API_KEY" \
31939 -H "Content-Type: application/json" \
31940 -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
31941 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
31942 <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
31943 </div>
31944 </div>
31945 </div>
31946
31947 <div class="ep-card">
31948 <div class="ep-header">
31949 <span class="method delete">DELETE</span>
31950 <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
31951 <span class="auth-badge protected">Protected</span>
31952 <span class="ep-desc">Delete a scan profile</span>
31953 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31954 </div>
31955 <div class="ep-body">
31956 <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
31957 <p class="params-heading">Path Parameters</p>
31958 <table class="params">
31959 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
31960 <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>
31961 </table>
31962 <details class="schema"><summary>Response schema</summary>
31963<div class="schema-block">{ "ok": true }</div></details>
31964 <p class="curl-heading">Example</p>
31965 <div class="curl-wrap">
31966 <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
31967 -H "Authorization: Bearer $SLOC_API_KEY" \
31968 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/<id></pre>
31969 <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
31970 </div>
31971 </div>
31972 </div>
31973 </div>
31974
31975 <!-- Scheduled Scans -->
31976 <div class="section">
31977 <h2 class="section-title">Scheduled Scans</h2>
31978
31979 <div class="ep-card">
31980 <div class="ep-header">
31981 <span class="method get">GET</span>
31982 <span class="ep-path">/api/schedules</span>
31983 <span class="auth-badge protected">Protected</span>
31984 <span class="ep-desc">List configured schedules</span>
31985 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
31986 </div>
31987 <div class="ep-body">
31988 <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
31989 <p class="curl-heading">Example</p>
31990 <div class="curl-wrap">
31991 <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
31992 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
31993 <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
31994 </div>
31995 </div>
31996 </div>
31997
31998 <div class="ep-card">
31999 <div class="ep-header">
32000 <span class="method post">POST</span>
32001 <span class="ep-path">/api/schedules</span>
32002 <span class="auth-badge protected">Protected</span>
32003 <span class="ep-desc">Create a schedule</span>
32004 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32005 </div>
32006 <div class="ep-body">
32007 <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>
32008 <p class="curl-heading">Example</p>
32009 <div class="curl-wrap">
32010 <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
32011 -H "Authorization: Bearer $SLOC_API_KEY" \
32012 -H "Content-Type: application/json" \
32013 -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
32014 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
32015 <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
32016 </div>
32017 </div>
32018 </div>
32019
32020 <div class="ep-card">
32021 <div class="ep-header">
32022 <span class="method delete">DELETE</span>
32023 <span class="ep-path">/api/schedules</span>
32024 <span class="auth-badge protected">Protected</span>
32025 <span class="ep-desc">Delete a schedule</span>
32026 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32027 </div>
32028 <div class="ep-body">
32029 <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
32030 <p class="curl-heading">Example</p>
32031 <div class="curl-wrap">
32032 <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
32033 -H "Authorization: Bearer $SLOC_API_KEY" \
32034 -H "Content-Type: application/json" \
32035 -d '{"id":"<schedule_id>"}' \
32036 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
32037 <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
32038 </div>
32039 </div>
32040 </div>
32041 </div>
32042
32043 <!-- Git Browser -->
32044 <div class="section">
32045 <h2 class="section-title">Git Browser</h2>
32046
32047 <div class="ep-card">
32048 <div class="ep-header">
32049 <span class="method get">GET</span>
32050 <span class="ep-path">/api/git/refs</span>
32051 <span class="auth-badge protected">Protected</span>
32052 <span class="ep-desc">List git refs for a repository</span>
32053 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32054 </div>
32055 <div class="ep-body">
32056 <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
32057 <p class="params-heading">Query Parameters</p>
32058 <table class="params">
32059 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32060 <tr><td class="pt-name">repo</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>
32061 </table>
32062 <p class="curl-heading">Example</p>
32063 <div class="curl-wrap">
32064 <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32065 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?repo=/path/to/repo"</pre>
32066 <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
32067 </div>
32068 </div>
32069 </div>
32070
32071 <div class="ep-card">
32072 <div class="ep-header">
32073 <span class="method get">GET</span>
32074 <span class="ep-path">/api/git/scan-ref</span>
32075 <span class="auth-badge protected">Protected</span>
32076 <span class="ep-desc">SLOC-scan a specific git ref</span>
32077 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32078 </div>
32079 <div class="ep-body">
32080 <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
32081 <p class="params-heading">Query Parameters</p>
32082 <table class="params">
32083 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32084 <tr><td class="pt-name">repo</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>
32085 <tr><td class="pt-name">ref_name</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Branch name, tag, or commit SHA</td></tr>
32086 </table>
32087 <p class="curl-heading">Example</p>
32088 <div class="curl-wrap">
32089 <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32090 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?repo=/path/to/repo&ref_name=main"</pre>
32091 <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
32092 </div>
32093 </div>
32094 </div>
32095
32096 <div class="ep-card">
32097 <div class="ep-header">
32098 <span class="method get">GET</span>
32099 <span class="ep-path">/api/git/compare-refs</span>
32100 <span class="auth-badge protected">Protected</span>
32101 <span class="ep-desc">Compare SLOC across two git refs</span>
32102 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32103 </div>
32104 <div class="ep-body">
32105 <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
32106 <p class="params-heading">Query Parameters</p>
32107 <table class="params">
32108 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32109 <tr><td class="pt-name">repo</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>
32110 <tr><td class="pt-name">baseline_ref</td><td class="pt-type">string</td><td><span class="pt-req">required</span></td><td>Base ref (branch, tag, or SHA)</td></tr>
32111 <tr><td class="pt-name">current_ref</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>
32112 </table>
32113 <p class="curl-heading">Example</p>
32114 <div class="curl-wrap">
32115 <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32116 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/compare-refs?repo=/path/to/repo&baseline_ref=v1.0&current_ref=main"</pre>
32117 <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
32118 </div>
32119 </div>
32120 </div>
32121 </div>
32122
32123 <!-- Webhooks -->
32124 <div class="section">
32125 <h2 class="section-title">Webhooks</h2>
32126 <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>
32127
32128 <div class="ep-card">
32129 <div class="ep-header">
32130 <span class="method post">POST</span>
32131 <span class="ep-path">/webhooks/github</span>
32132 <span class="auth-badge hmac">HMAC</span>
32133 <span class="ep-desc">GitHub push event receiver</span>
32134 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32135 </div>
32136 <div class="ep-body">
32137 <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>
32138 <p class="params-heading">Required Headers</p>
32139 <table class="params">
32140 <tr><th>Header</th><th>Value</th></tr>
32141 <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
32142 <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
32143 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
32144 </table>
32145 </div>
32146 </div>
32147
32148 <div class="ep-card">
32149 <div class="ep-header">
32150 <span class="method post">POST</span>
32151 <span class="ep-path">/webhooks/gitlab</span>
32152 <span class="auth-badge hmac">HMAC</span>
32153 <span class="ep-desc">GitLab push event receiver</span>
32154 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32155 </div>
32156 <div class="ep-body">
32157 <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>
32158 <p class="params-heading">Required Headers</p>
32159 <table class="params">
32160 <tr><th>Header</th><th>Value</th></tr>
32161 <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
32162 <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
32163 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
32164 </table>
32165 </div>
32166 </div>
32167
32168 <div class="ep-card">
32169 <div class="ep-header">
32170 <span class="method post">POST</span>
32171 <span class="ep-path">/webhooks/bitbucket</span>
32172 <span class="auth-badge hmac">HMAC</span>
32173 <span class="ep-desc">Bitbucket push event receiver</span>
32174 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32175 </div>
32176 <div class="ep-body">
32177 <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
32178 <p class="params-heading">Required Headers</p>
32179 <table class="params">
32180 <tr><th>Header</th><th>Value</th></tr>
32181 <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
32182 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
32183 </table>
32184 </div>
32185 </div>
32186 </div>
32187
32188 <!-- Config -->
32189 <div class="section">
32190 <h2 class="section-title">Config Import / Export</h2>
32191
32192 <div class="ep-card">
32193 <div class="ep-header">
32194 <span class="method get">GET</span>
32195 <span class="ep-path">/export-config</span>
32196 <span class="auth-badge protected">Protected</span>
32197 <span class="ep-desc">Export server configuration as JSON</span>
32198 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32199 </div>
32200 <div class="ep-body">
32201 <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
32202 <p class="curl-heading">Example</p>
32203 <div class="curl-wrap">
32204 <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32205 -o config.json \
32206 <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
32207 <button class="curl-copy-btn" data-target="c-export">Copy</button>
32208 </div>
32209 </div>
32210 </div>
32211
32212 <div class="ep-card">
32213 <div class="ep-header">
32214 <span class="method post">POST</span>
32215 <span class="ep-path">/import-config</span>
32216 <span class="auth-badge protected">Protected</span>
32217 <span class="ep-desc">Import server configuration</span>
32218 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32219 </div>
32220 <div class="ep-body">
32221 <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
32222 <p class="curl-heading">Example</p>
32223 <div class="curl-wrap">
32224 <pre class="curl-block" data-curl-id="c-import">curl -X POST \
32225 -H "Authorization: Bearer $SLOC_API_KEY" \
32226 -H "Content-Type: application/json" \
32227 -d @config.json \
32228 <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
32229 <button class="curl-copy-btn" data-target="c-import">Copy</button>
32230 </div>
32231 </div>
32232 </div>
32233 </div>
32234
32235 <!-- CI Ingest -->
32236 <div class="section">
32237 <h2 class="section-title">CI Ingest</h2>
32238
32239 <div class="ep-card">
32240 <div class="ep-header">
32241 <span class="method post">POST</span>
32242 <span class="ep-path">/api/ingest</span>
32243 <span class="auth-badge protected">Protected</span>
32244 <span class="ep-desc">Push a pre-computed scan result from CI</span>
32245 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32246 </div>
32247 <div class="ep-body">
32248 <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>
32249 <p class="params-heading">Query Parameters</p>
32250 <table class="params">
32251 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32252 <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>
32253 </table>
32254 <p class="params-heading">Request Body (application/json)</p>
32255 <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>
32256 <details class="schema"><summary>Response schema</summary>
32257<div class="schema-block">// 201 Created
32258{
32259 "run_id": string, // UUID of the ingested run
32260 "view_url": string // relative URL to the report page
32261}</div></details>
32262 <p class="curl-heading">Example</p>
32263 <div class="curl-wrap">
32264 <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
32265 -H "Authorization: Bearer $SLOC_API_KEY" \
32266 -H "Content-Type: application/json" \
32267 -d @result.json \
32268 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
32269 <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
32270 </div>
32271 </div>
32272 </div>
32273 </div>
32274
32275 <!-- Artifact Download -->
32276 <div class="section">
32277 <h2 class="section-title">Artifact Download</h2>
32278
32279 <div class="ep-card">
32280 <div class="ep-header">
32281 <span class="method get">GET</span>
32282 <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
32283 <span class="auth-badge protected">Protected</span>
32284 <span class="ep-desc">Download or view a scan artifact</span>
32285 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32286 </div>
32287 <div class="ep-body">
32288 <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
32289 <p class="params-heading">Path Parameters</p>
32290 <table class="params">
32291 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32292 <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>
32293 <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>
32294 </table>
32295 <p class="params-heading">Query Parameters</p>
32296 <table class="params">
32297 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32298 <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>
32299 </table>
32300 <p class="curl-heading">Example — download JSON result</p>
32301 <div class="curl-wrap">
32302 <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32303 -o result.json \
32304 "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/<run_id>?download=1"</pre>
32305 <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
32306 </div>
32307 </div>
32308 </div>
32309 </div>
32310
32311 <!-- Embed Widget -->
32312 <div class="section">
32313 <h2 class="section-title">Embed Widget</h2>
32314
32315 <div class="ep-card">
32316 <div class="ep-header">
32317 <span class="method get">GET</span>
32318 <span class="ep-path">/embed/summary</span>
32319 <span class="auth-badge protected">Protected</span>
32320 <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
32321 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32322 </div>
32323 <div class="ep-body">
32324 <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>
32325 <p class="params-heading">Query Parameters</p>
32326 <table class="params">
32327 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32328 <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>
32329 <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>
32330 </table>
32331 <p class="curl-heading">Example</p>
32332 <div class="curl-wrap">
32333 <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"
32334 width="460" height="260" style="border:none"></iframe></pre>
32335 <button class="curl-copy-btn" data-target="c-embed">Copy</button>
32336 </div>
32337 </div>
32338 </div>
32339 </div>
32340
32341 <!-- Confluence Integration -->
32342 <div class="section">
32343 <h2 class="section-title">Confluence Integration</h2>
32344
32345 <div class="ep-card">
32346 <div class="ep-header">
32347 <span class="method get">GET</span>
32348 <span class="ep-path">/api/confluence/config</span>
32349 <span class="auth-badge protected">Protected</span>
32350 <span class="ep-desc">Get current Confluence configuration</span>
32351 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32352 </div>
32353 <div class="ep-body">
32354 <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
32355 <details class="schema"><summary>Response schema</summary>
32356<div class="schema-block">{
32357 "configured": boolean,
32358 "tier": "cloud" | "server",
32359 "base_url": string,
32360 "username": string,
32361 "api_token_set": boolean,
32362 "space_key": string,
32363 "parent_page_id": string | null,
32364 "schedule_auto_post": { "<schedule_id>": boolean }
32365}</div></details>
32366 <p class="curl-heading">Example</p>
32367 <div class="curl-wrap">
32368 <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32369 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
32370 <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
32371 </div>
32372 </div>
32373 </div>
32374
32375 <div class="ep-card">
32376 <div class="ep-header">
32377 <span class="method post">POST</span>
32378 <span class="ep-path">/api/confluence/config</span>
32379 <span class="auth-badge protected">Protected</span>
32380 <span class="ep-desc">Save Confluence configuration</span>
32381 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32382 </div>
32383 <div class="ep-body">
32384 <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
32385 <p class="params-heading">Request Body (application/json)</p>
32386 <table class="params">
32387 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32388 <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>
32389 <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>
32390 <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>
32391 <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>
32392 <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>
32393 <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>
32394 <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>
32395 </table>
32396 <details class="schema"><summary>Response schema</summary>
32397<div class="schema-block">{ "ok": true }</div></details>
32398 <p class="curl-heading">Example</p>
32399 <div class="curl-wrap">
32400 <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
32401 -H "Authorization: Bearer $SLOC_API_KEY" \
32402 -H "Content-Type: application/json" \
32403 -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
32404 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
32405 <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
32406 </div>
32407 </div>
32408 </div>
32409
32410 <div class="ep-card">
32411 <div class="ep-header">
32412 <span class="method post">POST</span>
32413 <span class="ep-path">/api/confluence/test</span>
32414 <span class="auth-badge protected">Protected</span>
32415 <span class="ep-desc">Test Confluence connection</span>
32416 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32417 </div>
32418 <div class="ep-body">
32419 <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
32420 <details class="schema"><summary>Response schema</summary>
32421<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
32422 <p class="curl-heading">Example</p>
32423 <div class="curl-wrap">
32424 <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
32425 -H "Authorization: Bearer $SLOC_API_KEY" \
32426 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
32427 <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
32428 </div>
32429 </div>
32430 </div>
32431
32432 <div class="ep-card">
32433 <div class="ep-header">
32434 <span class="method post">POST</span>
32435 <span class="ep-path">/api/confluence/post</span>
32436 <span class="auth-badge protected">Protected</span>
32437 <span class="ep-desc">Publish a scan report to Confluence</span>
32438 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32439 </div>
32440 <div class="ep-body">
32441 <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>
32442 <p class="params-heading">Request Body (application/json)</p>
32443 <table class="params">
32444 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32445 <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>
32446 <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>
32447 <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>
32448 </table>
32449 <details class="schema"><summary>Response schema</summary>
32450<div class="schema-block">// 200 OK
32451{ "ok": true, "page_id": string }
32452
32453// 400 / 502 on error
32454{ "ok": false, "error": string }</div></details>
32455 <p class="curl-heading">Example</p>
32456 <div class="curl-wrap">
32457 <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
32458 -H "Authorization: Bearer $SLOC_API_KEY" \
32459 -H "Content-Type: application/json" \
32460 -d '{"run_id":"<uuid>","page_title":"SLOC Report 2025-05-10"}' \
32461 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
32462 <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
32463 </div>
32464 </div>
32465 </div>
32466
32467 <div class="ep-card">
32468 <div class="ep-header">
32469 <span class="method get">GET</span>
32470 <span class="ep-path">/api/confluence/wiki-markup</span>
32471 <span class="auth-badge protected">Protected</span>
32472 <span class="ep-desc">Get Confluence wiki markup for a run</span>
32473 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32474 </div>
32475 <div class="ep-body">
32476 <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>
32477 <p class="params-heading">Query Parameters</p>
32478 <table class="params">
32479 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32480 <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>
32481 </table>
32482 <p class="curl-heading">Example</p>
32483 <div class="curl-wrap">
32484 <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32485 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=<uuid>"</pre>
32486 <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
32487 </div>
32488 </div>
32489 </div>
32490 </div>
32491
32492 <!-- Authentication -->
32493 <div class="section">
32494 <h2 class="section-title">Authentication</h2>
32495 <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
32496
32497 <div class="ep-card">
32498 <div class="ep-header">
32499 <span class="method get">GET</span>
32500 <span class="ep-path">/auth/login</span>
32501 <span class="auth-badge public">Public</span>
32502 <span class="ep-desc">Login page</span>
32503 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32504 </div>
32505 <div class="ep-body">
32506 <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>
32507 <p class="params-heading">Query Parameters</p>
32508 <table class="params">
32509 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32510 <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>
32511 <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>
32512 </table>
32513 </div>
32514 </div>
32515
32516 <div class="ep-card">
32517 <div class="ep-header">
32518 <span class="method post">POST</span>
32519 <span class="ep-path">/auth/login</span>
32520 <span class="auth-badge public">Public</span>
32521 <span class="ep-desc">Submit credentials and get a session cookie</span>
32522 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32523 </div>
32524 <div class="ep-body">
32525 <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>
32526 <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
32527 <table class="params">
32528 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
32529 <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>
32530 <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>
32531 </table>
32532 <p class="curl-heading">Example</p>
32533 <div class="curl-wrap">
32534 <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
32535 -d "key=$SLOC_API_KEY&next=/" \
32536 <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
32537 <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
32538 </div>
32539 </div>
32540 </div>
32541 </div>
32542
32543 <!-- Coverage Suggestion -->
32544 <div class="section">
32545 <h2 class="section-title">Coverage Suggestion</h2>
32546
32547 <div class="ep-card">
32548 <div class="ep-header">
32549 <span class="method get">GET</span>
32550 <span class="ep-path">/api/suggest-coverage</span>
32551 <span class="auth-badge protected">Protected</span>
32552 <span class="ep-desc">Auto-detect a coverage file for a project root</span>
32553 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
32554 </div>
32555 <div class="ep-body">
32556 <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>
32557 <p class="params-heading">Query Parameters</p>
32558 <table class="params">
32559 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
32560 <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>
32561 </table>
32562 <details class="schema"><summary>Response schema</summary>
32563<div class="schema-block">{
32564 "found": string | null, // absolute path to the coverage file, if detected
32565 "tool": string | null, // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
32566 "hint": string | null // shell command to generate coverage if not found
32567}</div></details>
32568 <p class="curl-heading">Example</p>
32569 <div class="curl-wrap">
32570 <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
32571 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
32572 <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
32573 </div>
32574 </div>
32575 </div>
32576 </div>
32577
32578 </div>
32579
32580 <footer class="site-footer">
32581 local code analysis - metrics, history and reports
32582 · <em class="footer-mode" id="footer-mode" style="font-style:italic;font-weight:700;color:var(--oxide);">oxide-sloc v{{ version }} — Mode: Local</em>
32583 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
32584 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
32585 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
32586 · <a href="/api-docs" rel="noopener">REST API</a>
32587 </footer>
32588
32589 <script nonce="{{ csp_nonce }}">
32590 (function () {
32591 var base = window.location.origin;
32592 document.getElementById('base-url').textContent = base;
32593 document.querySelectorAll('.base-url-slot').forEach(function (el) {
32594 el.textContent = base;
32595 });
32596
32597 document.querySelectorAll('.ep-header').forEach(function (hdr) {
32598 hdr.addEventListener('click', function () {
32599 hdr.closest('.ep-card').classList.toggle('open');
32600 });
32601 });
32602
32603 document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
32604 btn.addEventListener('click', function () {
32605 var targetId = btn.dataset.target;
32606 var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
32607 if (!pre) return;
32608 navigator.clipboard.writeText(pre.textContent).then(function () {
32609 btn.textContent = 'Copied!';
32610 btn.classList.add('copied');
32611 setTimeout(function () {
32612 btn.textContent = 'Copy';
32613 btn.classList.remove('copied');
32614 }, 2000);
32615 });
32616 });
32617 });
32618
32619 var storageKey = 'oxide-sloc-theme';
32620 try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
32621 var themeBtn = document.getElementById('theme-toggle');
32622 if (themeBtn) {
32623 themeBtn.addEventListener('click', function () {
32624 var dark = document.body.classList.toggle('dark-theme');
32625 try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
32626 });
32627 }
32628 (function() {
32629 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'}];
32630 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);});}
32631 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
32632 var btn=document.getElementById('settings-btn');if(!btn)return;
32633 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
32634 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>';
32635 document.body.appendChild(m);
32636 var g=document.getElementById('scheme-grid');
32637 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);});
32638 var cl=document.getElementById('settings-close');
32639 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);});})();
32640 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');});
32641 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
32642 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
32643 })();
32644 (function randomizeWatermarks() {
32645 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
32646 if (!wms.length) return;
32647 var placed = [];
32648 function tooClose(top, left) {
32649 for (var i = 0; i < placed.length; i++) {
32650 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
32651 if (dt < 16 && dl < 12) return true;
32652 }
32653 return false;
32654 }
32655 function pick(leftBand) {
32656 for (var attempt = 0; attempt < 50; attempt++) {
32657 var top = Math.random() * 88 + 2;
32658 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
32659 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
32660 }
32661 var top = Math.random() * 88 + 2;
32662 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
32663 placed.push([top, left]); return [top, left];
32664 }
32665 var half = Math.floor(wms.length / 2);
32666 wms.forEach(function (img, i) {
32667 var pos = pick(i < half);
32668 var size = Math.floor(Math.random() * 100 + 120);
32669 var rot = (Math.random() * 360).toFixed(1);
32670 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
32671 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;
32672 });
32673 })();
32674 (function spawnCodeParticles() {
32675 var container = document.getElementById('code-particles');
32676 if (!container) return;
32677 var snippets = [
32678 '1,247 sloc','fn analyze()','code_lines','0 mixed','blanks: 312',
32679 '// comment','pub fn run','use std::fs','Result<()>','let mut n = 0',
32680 'git main','#[derive]','impl Scan','3,841 physical','files: 60',
32681 '450 comments','cargo build','Ok(run)','Vec<String>','match lang',
32682 'fn main() {','.rs .go .py','sloc_core','render_html','2,163 code'
32683 ];
32684 var count = 38;
32685 for (var i = 0; i < count; i++) {
32686 (function(idx) {
32687 var el = document.createElement('span');
32688 el.className = 'code-particle';
32689 el.textContent = snippets[idx % snippets.length];
32690 var left = Math.random() * 94 + 2;
32691 var top = Math.random() * 88 + 6;
32692 var dur = (Math.random() * 10 + 9).toFixed(1);
32693 var delay = (Math.random() * 18).toFixed(1);
32694 var rot = (Math.random() * 26 - 13).toFixed(1);
32695 var op = (Math.random() * 0.09 + 0.06).toFixed(3);
32696 el.style.cssText = 'left:'+left.toFixed(1)+'%;top:'+top.toFixed(1)+'%;--rot:'+rot+'deg;--op:'+op+';animation-duration:'+dur+'s;animation-delay:-'+delay+'s;';
32697 container.appendChild(el);
32698 })(i);
32699 }
32700 })();
32701 }());
32702 </script>
32703</body>
32704</html>
32705"##,
32706 ext = "html"
32707)]
32708struct ApiDocsTemplate {
32709 has_api_key: bool,
32710 csp_nonce: String,
32711 version: &'static str,
32712}
32713
32714#[cfg(test)]
32715mod form_config_tests {
32716 use super::*;
32717 use sloc_config::{
32718 BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy, MixedLinePolicy,
32719 };
32720
32721 fn blank_form() -> AnalyzeForm {
32722 AnalyzeForm {
32723 path: ".".to_string(),
32724 git_repo: None,
32725 git_ref: None,
32726 mixed_line_policy: None,
32727 python_docstrings_as_comments: None,
32728 generated_file_detection: None,
32729 minified_file_detection: None,
32730 vendor_directory_detection: None,
32731 include_lockfiles: None,
32732 binary_file_behavior: None,
32733 output_dir: None,
32734 report_title: None,
32735 report_header_footer: None,
32736 include_globs: None,
32737 exclude_globs: None,
32738 submodule_breakdown: None,
32739 coverage_file: None,
32740 continuation_line_policy: None,
32741 blank_in_block_comment_policy: None,
32742 count_compiler_directives: None,
32743 style_col_threshold: None,
32744 style_analysis_enabled: None,
32745 style_score_threshold: None,
32746 style_lang_scope: None,
32747 cocomo_mode: None,
32748 complexity_alert: None,
32749 exclude_duplicates: None,
32750 activity_window: None,
32751 }
32752 }
32753
32754 fn apply(form: &AnalyzeForm) -> sloc_config::AppConfig {
32755 let mut cfg = sloc_config::AppConfig::default();
32756 apply_form_to_config(&mut cfg, form);
32757 cfg
32758 }
32759
32760 #[test]
32763 fn extract_long_commit_picks_super_repo_by_short_prefix() {
32764 let dir = tempfile::tempdir().unwrap();
32768 let path = dir.path().join("result.json");
32769 let body = r#"{
32770 "submodules": [
32771 { "git_commit_long": "aaaa111122223333444455556666777788889999" },
32772 { "git_commit_long": null }
32773 ],
32774 "git_commit_short": "4c2cd9b",
32775 "git_commit_long": "4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b"
32776}"#;
32777 std::fs::write(&path, body).unwrap();
32778 assert_eq!(
32779 super::extract_long_commit_from_json(&path, "4c2cd9b").as_deref(),
32780 Some("4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b")
32781 );
32782 assert_eq!(super::extract_long_commit_from_json(&path, "deadbee"), None);
32784 assert_eq!(super::extract_long_commit_from_json(&path, ""), None);
32785 }
32786
32787 #[test]
32788 fn activity_window_defaults_on_when_field_blank() {
32789 let cfg = apply(&blank_form());
32791 assert_eq!(cfg.analysis.activity_window_days, Some(90));
32792 }
32793
32794 #[test]
32795 fn activity_window_override_sets_days() {
32796 let mut form = blank_form();
32797 form.activity_window = Some("30".to_string());
32798 let cfg = apply(&form);
32799 assert_eq!(cfg.analysis.activity_window_days, Some(30));
32800 }
32801
32802 #[test]
32803 fn activity_window_zero_disables() {
32804 let mut form = blank_form();
32806 form.activity_window = Some("0".to_string());
32807 let cfg = apply(&form);
32808 assert_eq!(cfg.analysis.activity_window_days, Some(0));
32809 }
32810
32811 #[test]
32814 fn python_docstrings_false_when_unchecked() {
32815 let cfg = apply(&blank_form());
32817 assert!(
32818 !cfg.analysis.python_docstrings_as_comments,
32819 "absent python_docstrings_as_comments must map to false"
32820 );
32821 }
32822
32823 #[test]
32824 fn python_docstrings_true_when_checked() {
32825 let mut form = blank_form();
32827 form.python_docstrings_as_comments = Some("on".to_string());
32828 let cfg = apply(&form);
32829 assert!(cfg.analysis.python_docstrings_as_comments);
32830 }
32831
32832 #[test]
32833 fn python_docstrings_true_for_any_non_none_value() {
32834 let mut form = blank_form();
32836 form.python_docstrings_as_comments = Some("true".to_string());
32837 assert!(apply(&form).analysis.python_docstrings_as_comments);
32838 }
32839
32840 #[test]
32843 fn submodule_breakdown_false_when_unchecked() {
32844 let cfg = apply(&blank_form());
32845 assert!(
32846 !cfg.discovery.submodule_breakdown,
32847 "absent submodule_breakdown must map to false"
32848 );
32849 }
32850
32851 #[test]
32852 fn submodule_breakdown_true_when_value_enabled() {
32853 let mut form = blank_form();
32854 form.submodule_breakdown = Some("enabled".to_string());
32855 assert!(apply(&form).discovery.submodule_breakdown);
32856 }
32857
32858 #[test]
32859 fn submodule_breakdown_false_for_wrong_value() {
32860 let mut form = blank_form();
32862 form.submodule_breakdown = Some("on".to_string());
32863 assert!(
32864 !apply(&form).discovery.submodule_breakdown,
32865 "submodule_breakdown only becomes true for the exact value 'enabled'"
32866 );
32867 }
32868
32869 #[test]
32872 fn generated_detection_true_when_enabled() {
32873 let mut form = blank_form();
32874 form.generated_file_detection = Some("enabled".to_string());
32875 assert!(apply(&form).analysis.generated_file_detection);
32876 }
32877
32878 #[test]
32879 fn generated_detection_false_when_disabled() {
32880 let mut form = blank_form();
32881 form.generated_file_detection = Some("disabled".to_string());
32882 assert!(!apply(&form).analysis.generated_file_detection);
32883 }
32884
32885 #[test]
32886 fn generated_detection_true_when_absent() {
32887 assert!(
32889 apply(&blank_form()).analysis.generated_file_detection,
32890 "absent field must default to true (detection on)"
32891 );
32892 }
32893
32894 #[test]
32897 fn minified_detection_false_when_disabled() {
32898 let mut form = blank_form();
32899 form.minified_file_detection = Some("disabled".to_string());
32900 assert!(!apply(&form).analysis.minified_file_detection);
32901 }
32902
32903 #[test]
32904 fn minified_detection_true_when_enabled() {
32905 let mut form = blank_form();
32906 form.minified_file_detection = Some("enabled".to_string());
32907 assert!(apply(&form).analysis.minified_file_detection);
32908 }
32909
32910 #[test]
32911 fn minified_detection_true_when_absent() {
32912 assert!(apply(&blank_form()).analysis.minified_file_detection);
32913 }
32914
32915 #[test]
32918 fn vendor_detection_false_when_disabled() {
32919 let mut form = blank_form();
32920 form.vendor_directory_detection = Some("disabled".to_string());
32921 assert!(!apply(&form).analysis.vendor_directory_detection);
32922 }
32923
32924 #[test]
32925 fn vendor_detection_true_when_enabled() {
32926 let mut form = blank_form();
32927 form.vendor_directory_detection = Some("enabled".to_string());
32928 assert!(apply(&form).analysis.vendor_directory_detection);
32929 }
32930
32931 #[test]
32932 fn vendor_detection_true_when_absent() {
32933 assert!(apply(&blank_form()).analysis.vendor_directory_detection);
32934 }
32935
32936 #[test]
32939 fn lockfiles_false_when_absent() {
32940 assert!(!apply(&blank_form()).analysis.include_lockfiles);
32942 }
32943
32944 #[test]
32945 fn lockfiles_false_when_disabled() {
32946 let mut form = blank_form();
32947 form.include_lockfiles = Some("disabled".to_string());
32948 assert!(!apply(&form).analysis.include_lockfiles);
32949 }
32950
32951 #[test]
32952 fn lockfiles_true_when_enabled() {
32953 let mut form = blank_form();
32954 form.include_lockfiles = Some("enabled".to_string());
32955 assert!(apply(&form).analysis.include_lockfiles);
32956 }
32957
32958 #[test]
32961 fn compiler_directives_true_when_absent() {
32962 assert!(
32963 apply(&blank_form()).analysis.count_compiler_directives,
32964 "absent count_compiler_directives must default to true"
32965 );
32966 }
32967
32968 #[test]
32969 fn compiler_directives_true_when_enabled() {
32970 let mut form = blank_form();
32971 form.count_compiler_directives = Some("enabled".to_string());
32972 assert!(apply(&form).analysis.count_compiler_directives);
32973 }
32974
32975 #[test]
32976 fn compiler_directives_false_when_disabled() {
32977 let mut form = blank_form();
32978 form.count_compiler_directives = Some("disabled".to_string());
32979 assert!(!apply(&form).analysis.count_compiler_directives);
32980 }
32981
32982 #[test]
32985 fn mixed_policy_unchanged_when_absent() {
32986 assert_eq!(
32988 apply(&blank_form()).analysis.mixed_line_policy,
32989 MixedLinePolicy::CodeOnly
32990 );
32991 }
32992
32993 #[test]
32994 fn mixed_policy_code_only() {
32995 let mut form = blank_form();
32996 form.mixed_line_policy = Some(MixedLinePolicy::CodeOnly);
32997 assert_eq!(
32998 apply(&form).analysis.mixed_line_policy,
32999 MixedLinePolicy::CodeOnly
33000 );
33001 }
33002
33003 #[test]
33004 fn mixed_policy_code_and_comment() {
33005 let mut form = blank_form();
33006 form.mixed_line_policy = Some(MixedLinePolicy::CodeAndComment);
33007 assert_eq!(
33008 apply(&form).analysis.mixed_line_policy,
33009 MixedLinePolicy::CodeAndComment
33010 );
33011 }
33012
33013 #[test]
33014 fn mixed_policy_comment_only() {
33015 let mut form = blank_form();
33016 form.mixed_line_policy = Some(MixedLinePolicy::CommentOnly);
33017 assert_eq!(
33018 apply(&form).analysis.mixed_line_policy,
33019 MixedLinePolicy::CommentOnly
33020 );
33021 }
33022
33023 #[test]
33024 fn mixed_policy_separate_mixed_category() {
33025 let mut form = blank_form();
33026 form.mixed_line_policy = Some(MixedLinePolicy::SeparateMixedCategory);
33027 assert_eq!(
33028 apply(&form).analysis.mixed_line_policy,
33029 MixedLinePolicy::SeparateMixedCategory
33030 );
33031 }
33032
33033 #[test]
33036 fn binary_behavior_skip_when_absent() {
33037 assert_eq!(
33038 apply(&blank_form()).analysis.binary_file_behavior,
33039 BinaryFileBehavior::Skip
33040 );
33041 }
33042
33043 #[test]
33044 fn binary_behavior_skip() {
33045 let mut form = blank_form();
33046 form.binary_file_behavior = Some(BinaryFileBehavior::Skip);
33047 assert_eq!(
33048 apply(&form).analysis.binary_file_behavior,
33049 BinaryFileBehavior::Skip
33050 );
33051 }
33052
33053 #[test]
33054 fn binary_behavior_fail() {
33055 let mut form = blank_form();
33056 form.binary_file_behavior = Some(BinaryFileBehavior::Fail);
33057 assert_eq!(
33058 apply(&form).analysis.binary_file_behavior,
33059 BinaryFileBehavior::Fail
33060 );
33061 }
33062
33063 #[test]
33066 fn continuation_policy_each_physical_when_absent() {
33067 assert_eq!(
33068 apply(&blank_form()).analysis.continuation_line_policy,
33069 ContinuationLinePolicy::EachPhysicalLine
33070 );
33071 }
33072
33073 #[test]
33074 fn continuation_policy_collapse_to_logical() {
33075 let mut form = blank_form();
33076 form.continuation_line_policy = Some(ContinuationLinePolicy::CollapseToLogical);
33077 assert_eq!(
33078 apply(&form).analysis.continuation_line_policy,
33079 ContinuationLinePolicy::CollapseToLogical
33080 );
33081 }
33082
33083 #[test]
33086 fn blank_in_block_comment_count_as_comment_when_absent() {
33087 assert_eq!(
33088 apply(&blank_form()).analysis.blank_in_block_comment_policy,
33089 BlankInBlockCommentPolicy::CountAsComment
33090 );
33091 }
33092
33093 #[test]
33094 fn blank_in_block_comment_count_as_blank() {
33095 let mut form = blank_form();
33096 form.blank_in_block_comment_policy = Some(BlankInBlockCommentPolicy::CountAsBlank);
33097 assert_eq!(
33098 apply(&form).analysis.blank_in_block_comment_policy,
33099 BlankInBlockCommentPolicy::CountAsBlank
33100 );
33101 }
33102
33103 #[test]
33106 fn style_threshold_80() {
33107 let mut form = blank_form();
33108 form.style_col_threshold = Some("80".to_string());
33109 assert_eq!(apply(&form).analysis.style_col_threshold, 80);
33110 }
33111
33112 #[test]
33113 fn style_threshold_100() {
33114 let mut form = blank_form();
33115 form.style_col_threshold = Some("100".to_string());
33116 assert_eq!(apply(&form).analysis.style_col_threshold, 100);
33117 }
33118
33119 #[test]
33120 fn style_threshold_120() {
33121 let mut form = blank_form();
33122 form.style_col_threshold = Some("120".to_string());
33123 assert_eq!(apply(&form).analysis.style_col_threshold, 120);
33124 }
33125
33126 #[test]
33127 fn style_threshold_invalid_value_leaves_default() {
33128 let mut cfg = sloc_config::AppConfig::default();
33130 let mut form = blank_form();
33131 form.style_col_threshold = Some("42".to_string());
33132 apply_form_to_config(&mut cfg, &form);
33133 assert_eq!(
33134 cfg.analysis.style_col_threshold, 80,
33135 "invalid threshold must not change config"
33136 );
33137 }
33138
33139 #[test]
33140 fn style_threshold_non_numeric_leaves_default() {
33141 let mut cfg = sloc_config::AppConfig::default();
33142 let mut form = blank_form();
33143 form.style_col_threshold = Some("large".to_string());
33144 apply_form_to_config(&mut cfg, &form);
33145 assert_eq!(cfg.analysis.style_col_threshold, 80);
33146 }
33147
33148 #[test]
33149 fn style_threshold_zero_leaves_default() {
33150 let mut cfg = sloc_config::AppConfig::default();
33151 let mut form = blank_form();
33152 form.style_col_threshold = Some("0".to_string());
33153 apply_form_to_config(&mut cfg, &form);
33154 assert_eq!(cfg.analysis.style_col_threshold, 80);
33155 }
33156
33157 #[test]
33158 fn style_threshold_absent_leaves_default() {
33159 assert_eq!(apply(&blank_form()).analysis.style_col_threshold, 80);
33160 }
33161
33162 #[test]
33165 fn style_score_threshold_zero_when_absent() {
33166 assert_eq!(apply(&blank_form()).analysis.style_score_threshold, 0);
33167 }
33168
33169 #[test]
33170 fn style_score_threshold_set_to_valid_value() {
33171 let mut form = blank_form();
33172 form.style_score_threshold = Some("70".to_string());
33173 assert_eq!(apply(&form).analysis.style_score_threshold, 70);
33174 }
33175
33176 #[test]
33177 fn style_score_threshold_clamps_to_100_when_over() {
33178 let mut form = blank_form();
33180 form.style_score_threshold = Some("200".to_string());
33181 assert_eq!(
33182 apply(&form).analysis.style_score_threshold,
33183 100,
33184 "style_score_threshold must be clamped to 100 when the submitted value exceeds it"
33185 );
33186 }
33187
33188 #[test]
33191 fn coverage_file_none_when_absent() {
33192 assert!(apply(&blank_form()).analysis.coverage_file.is_none());
33193 }
33194
33195 #[test]
33196 fn coverage_file_none_when_whitespace_only() {
33197 let mut form = blank_form();
33198 form.coverage_file = Some(" ".to_string());
33199 assert!(
33200 apply(&form).analysis.coverage_file.is_none(),
33201 "whitespace-only coverage_file must be treated as None"
33202 );
33203 }
33204
33205 #[test]
33206 fn coverage_file_set_when_non_empty() {
33207 let mut form = blank_form();
33208 form.coverage_file = Some("coverage/lcov.info".to_string());
33209 assert_eq!(
33210 apply(&form).analysis.coverage_file,
33211 Some(std::path::PathBuf::from("coverage/lcov.info"))
33212 );
33213 }
33214
33215 #[test]
33216 fn coverage_file_trims_whitespace() {
33217 let mut form = blank_form();
33218 form.coverage_file = Some(" coverage/lcov.info ".to_string());
33219 assert_eq!(
33220 apply(&form).analysis.coverage_file,
33221 Some(std::path::PathBuf::from("coverage/lcov.info"))
33222 );
33223 }
33224
33225 #[test]
33228 fn report_title_unchanged_when_absent() {
33229 let original = sloc_config::AppConfig::default().reporting.report_title;
33230 assert_eq!(apply(&blank_form()).reporting.report_title, original);
33231 }
33232
33233 #[test]
33234 fn report_title_unchanged_when_whitespace_only() {
33235 let original = sloc_config::AppConfig::default().reporting.report_title;
33236 let mut form = blank_form();
33237 form.report_title = Some(" ".to_string());
33238 assert_eq!(
33239 apply(&form).reporting.report_title,
33240 original,
33241 "whitespace-only title must not overwrite the default"
33242 );
33243 }
33244
33245 #[test]
33246 fn report_title_updated_and_trimmed() {
33247 let mut form = blank_form();
33248 form.report_title = Some(" My Project ".to_string());
33249 assert_eq!(apply(&form).reporting.report_title, "My Project");
33250 }
33251
33252 #[test]
33255 fn header_footer_none_when_absent() {
33256 assert!(
33257 apply(&blank_form())
33258 .reporting
33259 .report_header_footer
33260 .is_none()
33261 );
33262 }
33263
33264 #[test]
33265 fn header_footer_none_when_whitespace_only() {
33266 let mut form = blank_form();
33267 form.report_header_footer = Some(" ".to_string());
33268 assert!(apply(&form).reporting.report_header_footer.is_none());
33269 }
33270
33271 #[test]
33272 fn header_footer_set_and_trimmed() {
33273 let mut form = blank_form();
33274 form.report_header_footer = Some(" Confidential — Internal Use ".to_string());
33275 assert_eq!(
33276 apply(&form).reporting.report_header_footer,
33277 Some("Confidential — Internal Use".to_string())
33278 );
33279 }
33280
33281 #[test]
33284 fn include_globs_empty_when_absent() {
33285 assert!(apply(&blank_form()).discovery.include_globs.is_empty());
33286 }
33287
33288 #[test]
33289 fn include_globs_newline_separated() {
33290 let mut form = blank_form();
33291 form.include_globs = Some("src/**/*.rs\ntests/**/*.rs".to_string());
33292 assert_eq!(
33293 apply(&form).discovery.include_globs,
33294 vec!["src/**/*.rs", "tests/**/*.rs"]
33295 );
33296 }
33297
33298 #[test]
33299 fn exclude_globs_comma_separated() {
33300 let mut form = blank_form();
33301 form.exclude_globs = Some("vendor/**,node_modules/**".to_string());
33302 assert_eq!(
33303 apply(&form).discovery.exclude_globs,
33304 vec!["vendor/**", "node_modules/**"]
33305 );
33306 }
33307
33308 #[test]
33309 fn globs_mixed_separators() {
33310 let mut form = blank_form();
33311 form.exclude_globs = Some("a/**\nb/**,c/**".to_string());
33312 assert_eq!(
33313 apply(&form).discovery.exclude_globs,
33314 vec!["a/**", "b/**", "c/**"]
33315 );
33316 }
33317
33318 #[test]
33321 fn split_patterns_none_is_empty() {
33322 assert!(split_patterns(None).is_empty());
33323 }
33324
33325 #[test]
33326 fn split_patterns_empty_string_is_empty() {
33327 assert!(split_patterns(Some("")).is_empty());
33328 }
33329
33330 #[test]
33331 fn split_patterns_whitespace_only_is_empty() {
33332 assert!(split_patterns(Some(" \n \n ")).is_empty());
33333 }
33334
33335 #[test]
33336 fn split_patterns_newlines() {
33337 assert_eq!(
33338 split_patterns(Some("a/**\nb/**\nc/**")),
33339 vec!["a/**", "b/**", "c/**"]
33340 );
33341 }
33342
33343 #[test]
33344 fn split_patterns_commas() {
33345 assert_eq!(
33346 split_patterns(Some("a/**,b/**,c/**")),
33347 vec!["a/**", "b/**", "c/**"]
33348 );
33349 }
33350
33351 #[test]
33352 fn split_patterns_mixed() {
33353 assert_eq!(
33354 split_patterns(Some("a/**\nb/**,c/**")),
33355 vec!["a/**", "b/**", "c/**"]
33356 );
33357 }
33358
33359 #[test]
33360 fn split_patterns_trims_whitespace() {
33361 assert_eq!(
33362 split_patterns(Some(" a/** \n b/** ")),
33363 vec!["a/**", "b/**"]
33364 );
33365 }
33366
33367 #[test]
33368 fn split_patterns_filters_empty_entries() {
33369 assert_eq!(split_patterns(Some(",\n,,a/**,,\n")), vec!["a/**"]);
33370 }
33371
33372 #[test]
33373 fn split_patterns_single_entry() {
33374 assert_eq!(split_patterns(Some("src/**")), vec!["src/**"]);
33375 }
33376}
33377
33378#[cfg(test)]
33379mod utility_tests {
33380 use super::*;
33381 use std::net::IpAddr;
33382 use std::time::Duration;
33383
33384 #[test]
33387 fn sanitize_simple_name() {
33388 assert_eq!(sanitize_project_label("myrepo"), "myrepo");
33389 }
33390
33391 #[test]
33392 fn sanitize_uppercased_lowercased() {
33393 assert_eq!(sanitize_project_label("MyRepo"), "myrepo");
33394 }
33395
33396 #[test]
33397 fn sanitize_path_extracts_filename() {
33398 assert_eq!(
33399 sanitize_project_label("/home/user/my-project"),
33400 "my-project"
33401 );
33402 }
33403
33404 #[test]
33405 fn sanitize_path_uses_last_component() {
33406 assert_eq!(sanitize_project_label("/a/b/c/d"), "d");
33407 }
33408
33409 #[test]
33410 fn sanitize_spaces_become_hyphens() {
33411 assert_eq!(sanitize_project_label("my project"), "my-project");
33412 }
33413
33414 #[test]
33415 fn sanitize_non_ascii_become_hyphens() {
33416 assert_eq!(sanitize_project_label("proj\u{00e9}ct"), "proj-ct");
33417 }
33418
33419 #[test]
33420 fn sanitize_all_special_chars_gives_project() {
33421 assert_eq!(sanitize_project_label("!@#$%^"), "project");
33422 }
33423
33424 #[test]
33425 fn sanitize_empty_string_gives_project() {
33426 assert_eq!(sanitize_project_label(""), "project");
33427 }
33428
33429 #[test]
33430 fn sanitize_leading_trailing_hyphens_stripped() {
33431 assert_eq!(sanitize_project_label("!myrepo!"), "myrepo");
33432 }
33433
33434 #[test]
33435 fn sanitize_alphanumeric_preserved() {
33436 assert_eq!(sanitize_project_label("repo123"), "repo123");
33437 }
33438
33439 #[test]
33440 fn sanitize_dots_become_hyphens() {
33441 assert_eq!(sanitize_project_label("my.repo.name"), "my-repo-name");
33442 }
33443
33444 #[test]
33445 fn sanitize_mixed_slashes_uses_filename() {
33446 assert_eq!(sanitize_project_label("project-name"), "project-name");
33448 }
33449
33450 #[test]
33453 fn rate_limiter_allows_first_request() {
33454 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_hours(1));
33455 let ip: IpAddr = "127.0.0.1".parse().unwrap();
33456 assert!(rl.is_allowed(ip));
33457 }
33458
33459 #[test]
33460 fn rate_limiter_blocks_after_limit_reached() {
33461 let rl = IpRateLimiter::new(Duration::from_mins(1), 3, 5, Duration::from_hours(1));
33462 let ip: IpAddr = "10.0.0.1".parse().unwrap();
33463 assert!(rl.is_allowed(ip));
33464 assert!(rl.is_allowed(ip));
33465 assert!(rl.is_allowed(ip));
33466 assert!(!rl.is_allowed(ip), "4th request must be blocked");
33467 }
33468
33469 #[test]
33470 fn rate_limiter_allows_requests_up_to_limit() {
33471 let rl = IpRateLimiter::new(Duration::from_mins(1), 5, 5, Duration::from_hours(1));
33472 let ip: IpAddr = "10.0.0.2".parse().unwrap();
33473 for _ in 0..5 {
33474 assert!(rl.is_allowed(ip));
33475 }
33476 assert!(!rl.is_allowed(ip), "6th request must be blocked");
33477 }
33478
33479 #[test]
33480 fn rate_limiter_different_ips_are_independent() {
33481 let rl = IpRateLimiter::new(Duration::from_mins(1), 1, 5, Duration::from_hours(1));
33482 let ip1: IpAddr = "192.168.1.1".parse().unwrap();
33483 let ip2: IpAddr = "192.168.1.2".parse().unwrap();
33484 assert!(rl.is_allowed(ip1));
33485 assert!(!rl.is_allowed(ip1), "ip1 blocked after limit");
33486 assert!(rl.is_allowed(ip2), "ip2 must be independent");
33487 }
33488
33489 #[test]
33490 fn rate_limiter_auth_failure_not_locked_below_threshold() {
33491 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
33492 let ip: IpAddr = "10.0.0.3".parse().unwrap();
33493 rl.record_auth_failure(ip);
33494 rl.record_auth_failure(ip);
33495 assert!(
33496 !rl.is_auth_locked_out(ip),
33497 "not locked at 2 failures when threshold is 3"
33498 );
33499 }
33500
33501 #[test]
33502 fn rate_limiter_auth_failure_locked_at_threshold() {
33503 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
33504 let ip: IpAddr = "10.0.0.4".parse().unwrap();
33505 rl.record_auth_failure(ip);
33506 rl.record_auth_failure(ip);
33507 rl.record_auth_failure(ip);
33508 assert!(rl.is_auth_locked_out(ip), "must be locked after 3 failures");
33509 }
33510
33511 #[test]
33512 fn rate_limiter_auth_failure_different_ips_independent() {
33513 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 2, Duration::from_hours(1));
33514 let ip1: IpAddr = "10.0.1.1".parse().unwrap();
33515 let ip2: IpAddr = "10.0.1.2".parse().unwrap();
33516 rl.record_auth_failure(ip1);
33517 rl.record_auth_failure(ip1);
33518 assert!(rl.is_auth_locked_out(ip1));
33519 assert!(!rl.is_auth_locked_out(ip2), "ip2 must not be locked");
33520 }
33521
33522 #[test]
33523 fn rate_limiter_high_limit_never_blocks_normal_traffic() {
33524 let rl = IpRateLimiter::new(Duration::from_mins(1), 1000, 10, Duration::from_hours(1));
33525 let ip: IpAddr = "127.0.0.2".parse().unwrap();
33526 for _ in 0..100 {
33527 assert!(rl.is_allowed(ip));
33528 }
33529 }
33530
33531 #[test]
33534 fn strip_unc_plain_path_unchanged() {
33535 let p = PathBuf::from("C:\\Users\\user\\project");
33536 let result = strip_unc_prefix(p.clone());
33537 assert_eq!(result, p);
33538 }
33539
33540 #[test]
33541 fn strip_unc_with_drive_prefix_stripped() {
33542 let p = PathBuf::from(r"\\?\C:\Users\user\project");
33543 let result = strip_unc_prefix(p);
33544 assert_eq!(result, PathBuf::from(r"C:\Users\user\project"));
33545 }
33546
33547 #[test]
33548 fn strip_unc_with_network_prefix_stripped() {
33549 let p = PathBuf::from(r"\\?\UNC\server\share\dir");
33550 let result = strip_unc_prefix(p);
33551 assert_eq!(result, PathBuf::from(r"\\server\share\dir"));
33552 }
33553
33554 #[test]
33555 fn strip_unc_linux_path_unchanged() {
33556 let p = PathBuf::from("/home/user/project");
33557 let result = strip_unc_prefix(p.clone());
33558 assert_eq!(result, p);
33559 }
33560
33561 #[test]
33564 fn remote_to_commit_url_github_https() {
33565 let url = remote_to_commit_url("https://github.com/owner/repo.git", "abc1234");
33566 assert_eq!(
33567 url,
33568 Some("https://github.com/owner/repo/commit/abc1234".to_owned())
33569 );
33570 }
33571
33572 #[test]
33573 fn remote_to_commit_url_github_ssh() {
33574 let url = remote_to_commit_url("git@github.com:owner/repo.git", "abc1234");
33575 assert_eq!(
33576 url,
33577 Some("https://github.com/owner/repo/commit/abc1234".to_owned())
33578 );
33579 }
33580
33581 #[test]
33582 fn remote_to_commit_url_gitlab_uses_dash_commit() {
33583 let url = remote_to_commit_url("https://gitlab.com/group/repo.git", "deadbeef");
33584 assert_eq!(
33585 url,
33586 Some("https://gitlab.com/group/repo/-/commit/deadbeef".to_owned())
33587 );
33588 }
33589
33590 #[test]
33591 fn remote_to_commit_url_bitbucket_uses_commits() {
33592 let url = remote_to_commit_url("https://bitbucket.org/workspace/repo.git", "cafebabe");
33593 assert_eq!(
33594 url,
33595 Some("https://bitbucket.org/workspace/repo/commits/cafebabe".to_owned())
33596 );
33597 }
33598
33599 #[test]
33600 fn remote_to_commit_url_unknown_scheme_returns_none() {
33601 let url = remote_to_commit_url("ftp://example.com/repo.git", "abc");
33602 assert!(url.is_none());
33603 }
33604
33605 #[test]
33606 fn remote_to_commit_url_ssh_gitlab() {
33607 let url = remote_to_commit_url("git@gitlab.com:group/repo.git", "sha123");
33608 assert!(url.is_some());
33609 let u = url.unwrap();
33610 assert!(
33611 u.contains("/-/commit/sha123"),
33612 "gitlab ssh must use /-/commit/"
33613 );
33614 }
33615
33616 #[test]
33619 fn git_clone_dest_github_url_produces_safe_name() {
33620 let dir = PathBuf::from("/tmp/clones");
33621 let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
33622 let name = dest.file_name().unwrap().to_string_lossy();
33623 assert!(!name.is_empty());
33624 assert!(
33625 name.chars()
33626 .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.'),
33627 "clone dest must only contain safe chars, got: {name}"
33628 );
33629 }
33630
33631 #[test]
33632 fn git_clone_dest_is_inside_clones_dir() {
33633 let dir = PathBuf::from("/tmp/clones");
33634 let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
33635 assert!(
33636 dest.starts_with(&dir),
33637 "clone dest must be inside clones_dir"
33638 );
33639 }
33640
33641 #[test]
33642 fn git_clone_dest_truncates_to_80_chars_max() {
33643 let long_url = "https://github.com/".to_string() + &"a".repeat(200);
33644 let dir = PathBuf::from("/tmp/clones");
33645 let dest = git_clone_dest(&long_url, &dir);
33646 let name = dest.file_name().unwrap().to_string_lossy();
33647 assert!(
33648 name.len() <= 80,
33649 "clone dest name must be at most 80 chars, got {} chars: {name}",
33650 name.len()
33651 );
33652 }
33653
33654 #[test]
33655 fn git_clone_dest_special_chars_replaced_with_underscore() {
33656 let dir = PathBuf::from("/tmp/clones");
33657 let dest = git_clone_dest("git@github.com:owner/repo.git", &dir);
33658 let name = dest.file_name().unwrap().to_string_lossy();
33659 assert!(
33660 !name.contains('@') && !name.contains(':') && !name.contains('/'),
33661 "special chars must be replaced in clone dest, got: {name}"
33662 );
33663 }
33664
33665 #[test]
33666 fn git_clone_dest_different_urls_differ() {
33667 let dir = PathBuf::from("/tmp/clones");
33668 let a = git_clone_dest("https://github.com/owner/repo-a.git", &dir);
33669 let b = git_clone_dest("https://github.com/owner/repo-b.git", &dir);
33670 assert_ne!(
33671 a, b,
33672 "different repos must produce different clone dest names"
33673 );
33674 }
33675
33676 #[test]
33677 fn git_clone_dest_same_url_same_result() {
33678 let dir = PathBuf::from("/tmp/clones");
33679 let url = "https://github.com/owner/repo.git";
33680 assert_eq!(
33681 git_clone_dest(url, &dir),
33682 git_clone_dest(url, &dir),
33683 "same URL must always give same clone dest"
33684 );
33685 }
33686
33687 #[test]
33690 fn fmt_delta_positive_has_plus_prefix() {
33691 assert_eq!(fmt_delta(5), "+5");
33692 }
33693
33694 #[test]
33695 fn fmt_delta_negative_no_plus_prefix() {
33696 assert_eq!(fmt_delta(-3), "-3");
33697 }
33698
33699 #[test]
33700 fn fmt_delta_zero() {
33701 assert_eq!(fmt_delta(0), "0");
33702 }
33703
33704 #[test]
33707 fn delta_class_positive_is_pos() {
33708 assert_eq!(delta_class(1), "pos");
33709 }
33710
33711 #[test]
33712 fn delta_class_negative_is_neg() {
33713 assert_eq!(delta_class(-1), "neg");
33714 }
33715
33716 #[test]
33717 fn delta_class_zero_is_zero_class() {
33718 assert_eq!(delta_class(0), "zero");
33719 }
33720
33721 #[test]
33724 fn fmt_pct_zero_baseline_returns_em_dash() {
33725 assert_eq!(fmt_pct(100, 0), "\u{2014}");
33726 }
33727
33728 #[test]
33729 fn fmt_pct_positive_delta_has_plus_sign() {
33730 let result = fmt_pct(10, 100);
33731 assert!(result.starts_with('+'), "expected + prefix, got: {result}");
33732 }
33733
33734 #[test]
33735 fn fmt_pct_negative_delta_no_plus_sign() {
33736 let result = fmt_pct(-10, 100);
33737 assert!(!result.starts_with('+'), "unexpected + in: {result}");
33738 assert!(result.contains('%'));
33739 }
33740
33741 #[test]
33742 fn fmt_pct_near_zero_returns_pm_zero() {
33743 assert_eq!(fmt_pct(0, 1000), "\u{00b1}0%");
33744 }
33745
33746 #[test]
33749 fn summary_delta_no_prev_returns_dash_na() {
33750 let (display, class) = summary_delta(10, None);
33751 assert_eq!(display, "\u{2014}");
33752 assert_eq!(class, "na");
33753 }
33754
33755 #[test]
33756 fn summary_delta_increase_is_positive() {
33757 let (display, class) = summary_delta(15, Some(10));
33758 assert_eq!(display, "+5");
33759 assert_eq!(class, "pos");
33760 }
33761
33762 #[test]
33763 fn summary_delta_decrease_is_negative() {
33764 let (display, class) = summary_delta(5, Some(10));
33765 assert_eq!(display, "-5");
33766 assert_eq!(class, "neg");
33767 }
33768
33769 #[test]
33772 fn nth_weekday_first_monday_jan_2024_is_in_first_week() {
33773 use chrono::Datelike;
33774 let d = nth_weekday_of_month(2024, 1, chrono::Weekday::Mon, 1);
33775 assert_eq!(d.year(), 2024);
33776 assert_eq!(d.month(), 1);
33777 assert_eq!(d.weekday(), chrono::Weekday::Mon);
33778 assert!(d.day() <= 7);
33779 }
33780
33781 #[test]
33782 fn nth_weekday_second_sunday_march_2024_is_10th() {
33783 use chrono::Datelike;
33784 let d = nth_weekday_of_month(2024, 3, chrono::Weekday::Sun, 2);
33785 assert_eq!(d.weekday(), chrono::Weekday::Sun);
33786 assert_eq!(d.month(), 3);
33787 assert_eq!(d.day(), 10, "2nd Sunday in March 2024 is the 10th");
33788 }
33789
33790 #[test]
33793 fn is_pacific_dst_july_is_true() {
33794 let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
33795 assert!(is_pacific_dst(dt), "July must be PDT");
33796 }
33797
33798 #[test]
33799 fn is_pacific_dst_january_is_false() {
33800 let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
33801 assert!(!is_pacific_dst(dt), "January must be PST");
33802 }
33803
33804 #[test]
33805 fn fmt_la_time_summer_shows_pdt() {
33806 let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
33807 let result = fmt_la_time(dt);
33808 assert!(
33809 result.ends_with("PDT"),
33810 "summer must use PDT, got: {result}"
33811 );
33812 }
33813
33814 #[test]
33815 fn fmt_la_time_winter_shows_pst() {
33816 let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
33817 let result = fmt_la_time(dt);
33818 assert!(
33819 result.ends_with("PST"),
33820 "winter must use PST, got: {result}"
33821 );
33822 }
33823
33824 #[test]
33825 fn fmt_la_time_meta_summer_shows_pdt() {
33826 let dt: chrono::DateTime<chrono::Utc> = "2024-08-01T12:00:00Z".parse().unwrap();
33827 let result = fmt_la_time_meta(dt);
33828 assert!(
33829 result.ends_with("PDT"),
33830 "meta summer must use PDT, got: {result}"
33831 );
33832 }
33833
33834 #[test]
33835 fn fmt_la_time_meta_winter_shows_pst() {
33836 let dt: chrono::DateTime<chrono::Utc> = "2024-12-01T12:00:00Z".parse().unwrap();
33837 let result = fmt_la_time_meta(dt);
33838 assert!(
33839 result.ends_with("PST"),
33840 "meta winter must use PST, got: {result}"
33841 );
33842 }
33843
33844 #[test]
33847 fn fmt_git_date_valid_iso_returns_some() {
33848 assert!(fmt_git_date("2024-07-15T20:00:00Z").is_some());
33849 }
33850
33851 #[test]
33852 fn fmt_git_date_invalid_returns_none() {
33853 assert!(fmt_git_date("not-a-date").is_none());
33854 }
33855
33856 #[test]
33859 fn format_number_zero() {
33860 assert_eq!(format_number(0), "0");
33861 }
33862
33863 #[test]
33864 fn format_number_three_digits_no_comma() {
33865 assert_eq!(format_number(999), "999");
33866 }
33867
33868 #[test]
33869 fn format_number_four_digits_has_comma() {
33870 assert_eq!(format_number(1000), "1,000");
33871 }
33872
33873 #[test]
33874 fn format_number_seven_digits_two_commas() {
33875 assert_eq!(format_number(1_234_567), "1,234,567");
33876 }
33877
33878 #[test]
33879 fn format_number_one_million() {
33880 assert_eq!(format_number(1_000_000), "1,000,000");
33881 }
33882
33883 #[test]
33886 fn badge_text_px_empty_is_zero() {
33887 assert_eq!(badge_text_px(""), 0);
33888 }
33889
33890 #[test]
33891 fn badge_text_px_narrow_chars_smaller_than_normal() {
33892 assert!(
33893 badge_text_px("if") < badge_text_px("ab"),
33894 "'if' must be narrower than 'ab'"
33895 );
33896 }
33897
33898 #[test]
33899 fn badge_text_px_m_is_wider_than_a() {
33900 assert!(
33901 badge_text_px("m") > badge_text_px("a"),
33902 "'m' must be wider than 'a'"
33903 );
33904 }
33905
33906 #[test]
33907 fn render_badge_svg_contains_label_and_value() {
33908 let svg = render_badge_svg("coverage", "95%", "#4c1");
33909 assert!(svg.contains("coverage") && svg.contains("95%"));
33910 }
33911
33912 #[test]
33913 fn render_badge_svg_contains_color() {
33914 let svg = render_badge_svg("sloc", "12K", "#e05d44");
33915 assert!(svg.contains("#e05d44"), "SVG must contain fill color");
33916 }
33917
33918 #[test]
33919 fn render_badge_svg_escapes_ampersand_in_label() {
33920 let svg = render_badge_svg("test&label", "ok", "#4c1");
33921 assert!(svg.contains("&") && !svg.contains("test&label"));
33922 }
33923
33924 #[test]
33927 fn build_pdf_filename_slugifies_title() {
33928 let name = build_pdf_filename("My Project Report", "abc-def-1234");
33929 assert!(
33930 name.starts_with("my_project_report_")
33931 && std::path::Path::new(&name)
33932 .extension()
33933 .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
33934 );
33935 }
33936
33937 #[test]
33938 fn build_pdf_filename_uses_last_run_id_segment() {
33939 let name = build_pdf_filename("project", "uuid-part1-part2-ABCD");
33940 assert!(name.contains("ABCD"), "must use last segment of run_id");
33941 }
33942
33943 #[test]
33944 fn build_pdf_filename_empty_title_uses_report_prefix() {
33945 let name = build_pdf_filename("", "abc-def-9999");
33946 assert!(
33947 name.starts_with("report_")
33948 && std::path::Path::new(&name)
33949 .extension()
33950 .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
33951 );
33952 }
33953
33954 #[test]
33957 fn swap_chart_js_replaces_inline_block() {
33958 let html = "<html><head><script>// inline source</script></head><body></body></html>";
33959 let result = swap_inline_chart_js_for_static(html.to_string());
33960 assert!(result.contains(r#"src="/static/chart-report.js""#));
33961 assert!(!result.contains("inline source"));
33962 }
33963
33964 #[test]
33965 fn swap_chart_js_no_head_returns_unchanged() {
33966 let html = "<body>no head here</body>";
33967 assert_eq!(swap_inline_chart_js_for_static(html.to_string()), html);
33968 }
33969
33970 #[test]
33971 fn swap_chart_js_no_script_in_head_unchanged() {
33972 let html = "<html><head><style>.x{}</style></head><body></body></html>";
33973 let result = swap_inline_chart_js_for_static(html.to_string());
33974 assert!(!result.contains("chart-report.js"));
33975 }
33976
33977 #[test]
33980 fn patch_html_nonce_replaces_old_nonce() {
33981 let html = r#"<style nonce="old-nonce-123">body{}</style>"#;
33982 let result = patch_html_nonce(html, "new-nonce-456");
33983 assert!(result.contains(r#"nonce="new-nonce-456""#));
33984 assert!(!result.contains("old-nonce-123"));
33985 }
33986
33987 #[test]
33988 fn patch_html_nonce_injects_into_bare_style() {
33989 let html = "<style>body{color:red;}</style>";
33990 let result = patch_html_nonce(html, "fresh-nonce");
33991 assert!(result.contains(r#"<style nonce="fresh-nonce">"#));
33992 }
33993
33994 #[test]
33995 fn patch_html_nonce_injects_into_bare_script() {
33996 let html = "<script>console.log(1);</script>";
33997 let result = patch_html_nonce(html, "abc");
33998 assert!(result.contains(r#"<script nonce="abc">"#));
33999 }
34000
34001 #[test]
34004 fn is_html_report_file_result_html_matches() {
34005 let dir = tempfile::tempdir().unwrap();
34006 let path = dir.path().join("result_20240101.html");
34007 std::fs::write(&path, b"<html></html>").unwrap();
34008 assert!(is_html_report_file(&path));
34009 }
34010
34011 #[test]
34012 fn is_html_report_file_report_html_matches() {
34013 let dir = tempfile::tempdir().unwrap();
34014 let path = dir.path().join("report_abc.html");
34015 std::fs::write(&path, b"<html></html>").unwrap();
34016 assert!(is_html_report_file(&path));
34017 }
34018
34019 #[test]
34020 fn is_html_report_file_index_html_does_not_match() {
34021 let dir = tempfile::tempdir().unwrap();
34022 let path = dir.path().join("index.html");
34023 std::fs::write(&path, b"<html></html>").unwrap();
34024 assert!(!is_html_report_file(&path));
34025 }
34026
34027 #[test]
34028 fn is_html_report_file_nonexistent_returns_false() {
34029 assert!(!is_html_report_file(Path::new(
34030 "/nonexistent/result_xyz.html"
34031 )));
34032 }
34033
34034 #[test]
34035 fn find_html_report_in_dir_finds_result_html() {
34036 let dir = tempfile::tempdir().unwrap();
34037 std::fs::write(dir.path().join("result_xyz.html"), b"<html></html>").unwrap();
34038 assert!(find_html_report_in_dir(dir.path()).is_some());
34039 }
34040
34041 #[test]
34042 fn find_html_report_in_dir_empty_returns_none() {
34043 let dir = tempfile::tempdir().unwrap();
34044 assert!(find_html_report_in_dir(dir.path()).is_none());
34045 }
34046
34047 #[test]
34048 fn find_html_report_in_tree_finds_in_subdir() {
34049 let dir = tempfile::tempdir().unwrap();
34050 let subdir = dir.path().join("run-001");
34051 std::fs::create_dir_all(&subdir).unwrap();
34052 std::fs::write(subdir.join("result_abc.html"), b"<html></html>").unwrap();
34053 assert!(find_html_report_in_tree(dir.path()).is_some());
34054 }
34055
34056 #[test]
34059 fn derive_project_label_with_git_repo_and_ref() {
34060 let label = derive_project_label(
34061 Some("https://github.com/owner/my-repo.git"),
34062 Some("main"),
34063 "/fallback/path",
34064 );
34065 assert!(!label.is_empty(), "label must not be empty");
34066 assert!(
34067 label.contains("my") || label.contains("repo"),
34068 "got: {label}"
34069 );
34070 }
34071
34072 #[test]
34073 fn derive_project_label_fallback_to_path() {
34074 let label = derive_project_label(None, None, "/path/to/myproject");
34075 assert_eq!(label, "myproject");
34076 }
34077
34078 #[test]
34079 fn derive_project_label_empty_git_fields_use_path() {
34080 let label = derive_project_label(Some(""), Some(""), "/home/user/cool-app");
34081 assert_eq!(label, "cool-app");
34082 }
34083
34084 #[test]
34087 fn derive_file_stem_with_commit_appends_sha() {
34088 assert_eq!(
34089 derive_file_stem("myproject", Some("a1b2c3")),
34090 "myproject_a1b2c3"
34091 );
34092 }
34093
34094 #[test]
34095 fn derive_file_stem_without_commit_returns_label() {
34096 assert_eq!(derive_file_stem("myproject", None), "myproject");
34097 }
34098
34099 #[test]
34100 fn derive_file_stem_empty_commit_returns_label() {
34101 assert_eq!(derive_file_stem("myproject", Some("")), "myproject");
34102 }
34103
34104 #[test]
34107 fn split_patterns_none_is_empty() {
34108 assert!(split_patterns(None).is_empty());
34109 }
34110
34111 #[test]
34112 fn split_patterns_empty_string_is_empty() {
34113 assert!(split_patterns(Some("")).is_empty());
34114 }
34115
34116 #[test]
34117 fn split_patterns_comma_separated() {
34118 assert_eq!(
34119 split_patterns(Some("foo,bar,baz")),
34120 vec!["foo", "bar", "baz"]
34121 );
34122 }
34123
34124 #[test]
34125 fn split_patterns_newline_separated() {
34126 assert_eq!(
34127 split_patterns(Some("foo\nbar\nbaz")),
34128 vec!["foo", "bar", "baz"]
34129 );
34130 }
34131
34132 #[test]
34133 fn split_patterns_trims_whitespace() {
34134 assert_eq!(split_patterns(Some(" foo , bar ")), vec!["foo", "bar"]);
34135 }
34136
34137 #[test]
34140 fn make_git_label_empty_repo_empty_result() {
34141 assert_eq!(make_git_label("", "main"), "");
34142 }
34143
34144 #[test]
34145 fn make_git_label_empty_ref_empty_result() {
34146 assert_eq!(make_git_label("https://github.com/owner/repo", ""), "");
34147 }
34148
34149 #[test]
34150 fn make_git_label_basic_format() {
34151 assert_eq!(
34152 make_git_label("https://github.com/owner/my-repo.git", "main"),
34153 "my-repo_at_main_sloc"
34154 );
34155 }
34156
34157 #[test]
34158 fn make_git_label_slash_in_ref_replaced() {
34159 let label = make_git_label("https://example.com/repo.git", "feature/my-branch");
34160 assert!(
34161 !label.contains('/'),
34162 "slash in ref must be replaced: {label}"
34163 );
34164 }
34165
34166 #[test]
34169 fn format_dir_size_bytes() {
34170 assert_eq!(format_dir_size(500), "500 B");
34171 }
34172
34173 #[test]
34174 fn format_dir_size_kilobytes() {
34175 assert_eq!(format_dir_size(2048), "2 KB");
34176 }
34177
34178 #[test]
34179 fn format_dir_size_megabytes() {
34180 assert!(format_dir_size(5 * 1_048_576).contains("MB"));
34181 }
34182
34183 #[test]
34184 fn format_dir_size_gigabytes() {
34185 assert!(format_dir_size(2 * 1_073_741_824).contains("GB"));
34186 }
34187
34188 #[test]
34189 fn format_dir_size_zero() {
34190 assert_eq!(format_dir_size(0), "0 B");
34191 }
34192
34193 #[test]
34196 fn civil_from_days_epoch() {
34197 assert_eq!(civil_from_days(0), (1970, 1, 1));
34198 }
34199
34200 #[test]
34201 fn civil_from_days_one_year_later() {
34202 assert_eq!(civil_from_days(365), (1971, 1, 1));
34203 }
34204
34205 #[test]
34206 fn civil_from_days_31_days_is_feb_1_1970() {
34207 assert_eq!(civil_from_days(31), (1970, 2, 1));
34208 }
34209
34210 #[test]
34213 fn format_system_time_unix_epoch_formats_correctly() {
34214 assert_eq!(format_system_time(UNIX_EPOCH), "1970-01-01 00:00");
34215 }
34216
34217 #[test]
34218 fn format_system_time_31_days_after_epoch() {
34219 let t = UNIX_EPOCH + Duration::from_hours(744);
34220 assert_eq!(format_system_time(t), "1970-02-01 00:00");
34221 }
34222
34223 #[test]
34224 fn format_system_time_before_epoch_returns_dash() {
34225 if let Some(before) = UNIX_EPOCH.checked_sub(Duration::from_secs(1)) {
34226 assert_eq!(format_system_time(before), "-");
34227 }
34228 }
34229
34230 #[test]
34233 fn detect_language_name_dot_c() {
34234 assert_eq!(detect_language_name("main.c"), Some("C"));
34235 }
34236
34237 #[test]
34238 fn detect_language_name_dot_h() {
34239 assert_eq!(detect_language_name("defs.h"), Some("C"));
34240 }
34241
34242 #[test]
34243 fn detect_language_name_dot_cpp() {
34244 assert_eq!(detect_language_name("algo.cpp"), Some("C++"));
34245 }
34246
34247 #[test]
34248 fn detect_language_name_dot_py() {
34249 assert_eq!(detect_language_name("script.py"), Some("Python"));
34250 }
34251
34252 #[test]
34253 fn detect_language_name_dot_ps1() {
34254 assert_eq!(detect_language_name("Deploy.ps1"), Some("PowerShell"));
34255 }
34256
34257 #[test]
34258 fn detect_language_name_dot_cs() {
34259 assert_eq!(detect_language_name("Program.cs"), Some("C#"));
34260 }
34261
34262 #[test]
34263 fn detect_language_name_dot_sh() {
34264 assert_eq!(detect_language_name("run.sh"), Some("Shell"));
34265 }
34266
34267 #[test]
34268 fn detect_language_name_unknown_txt() {
34269 assert_eq!(detect_language_name("notes.txt"), None);
34270 }
34271
34272 #[test]
34275 fn language_icon_file_c() {
34276 assert_eq!(language_icon_file("C"), Some("c.png"));
34277 }
34278
34279 #[test]
34280 fn language_icon_file_python() {
34281 assert_eq!(language_icon_file("Python"), Some("python.png"));
34282 }
34283
34284 #[test]
34285 fn language_icon_file_dockerfile() {
34286 assert_eq!(language_icon_file("Dockerfile"), Some("docker.png"));
34287 }
34288
34289 #[test]
34290 fn language_icon_file_rust_is_none() {
34291 assert!(language_icon_file("Rust").is_none());
34292 }
34293
34294 #[test]
34295 fn language_icon_file_unknown_is_none() {
34296 assert!(language_icon_file("Fortran").is_none());
34297 }
34298
34299 #[test]
34302 fn language_inline_svg_rust_is_svg() {
34303 let svg = language_inline_svg("Rust").unwrap();
34304 assert!(svg.starts_with("<svg"));
34305 }
34306
34307 #[test]
34308 fn language_inline_svg_typescript_is_some() {
34309 assert!(language_inline_svg("TypeScript").is_some());
34310 }
34311
34312 #[test]
34313 fn language_inline_svg_unknown_is_none() {
34314 assert!(language_inline_svg("Fortran").is_none());
34315 }
34316
34317 #[test]
34320 fn classify_preview_file_c_supported() {
34321 assert!(matches!(
34322 classify_preview_file("main.c"),
34323 PreviewKind::Supported
34324 ));
34325 }
34326
34327 #[test]
34328 fn classify_preview_file_python_supported() {
34329 assert!(matches!(
34330 classify_preview_file("script.py"),
34331 PreviewKind::Supported
34332 ));
34333 }
34334
34335 #[test]
34336 fn classify_preview_file_png_skipped() {
34337 assert!(matches!(
34338 classify_preview_file("image.png"),
34339 PreviewKind::Skipped
34340 ));
34341 }
34342
34343 #[test]
34344 fn classify_preview_file_zip_skipped() {
34345 assert!(matches!(
34346 classify_preview_file("archive.zip"),
34347 PreviewKind::Skipped
34348 ));
34349 }
34350
34351 #[test]
34352 fn classify_preview_file_min_js_skipped() {
34353 assert!(matches!(
34354 classify_preview_file("bundle.min.js"),
34355 PreviewKind::Skipped
34356 ));
34357 }
34358
34359 #[test]
34360 fn classify_preview_file_rs_unsupported() {
34361 assert!(matches!(
34362 classify_preview_file("main.rs"),
34363 PreviewKind::Unsupported
34364 ));
34365 }
34366
34367 #[test]
34370 fn preview_relative_path_strips_root() {
34371 let root = PathBuf::from("/project");
34372 let path = PathBuf::from("/project/src/main.c");
34373 assert_eq!(preview_relative_path(&root, &path), "src/main.c");
34374 }
34375
34376 #[test]
34377 fn preview_relative_path_unrooted_includes_filename() {
34378 let root = PathBuf::from("/other");
34379 let path = PathBuf::from("/project/src/main.c");
34380 let result = preview_relative_path(&root, &path);
34381 assert!(result.contains("main.c"));
34382 }
34383
34384 #[test]
34385 fn preview_relative_path_uses_forward_slashes() {
34386 let root = PathBuf::from("/project");
34387 let path = PathBuf::from("/project/a/b/c.py");
34388 assert!(!preview_relative_path(&root, &path).contains('\\'));
34389 }
34390
34391 #[test]
34394 fn wildcard_match_exact_equal() {
34395 assert!(wildcard_match("foo", "foo"));
34396 }
34397
34398 #[test]
34399 fn wildcard_match_exact_mismatch() {
34400 assert!(!wildcard_match("foo", "bar"));
34401 }
34402
34403 #[test]
34404 fn wildcard_match_star_suffix() {
34405 assert!(wildcard_match("*.rs", "main.rs"));
34406 }
34407
34408 #[test]
34409 fn wildcard_match_star_middle_requires_suffix() {
34410 assert!(!wildcard_match("a*b", "ac"));
34411 }
34412
34413 #[test]
34414 fn wildcard_match_question_mark_single_char() {
34415 assert!(wildcard_match("f?o", "foo"));
34416 }
34417
34418 #[test]
34419 fn wildcard_match_double_star_nested() {
34420 assert!(wildcard_match("src/**", "src/a/b/c.rs"));
34421 }
34422
34423 #[test]
34424 fn wildcard_match_star_directory_entry() {
34425 assert!(wildcard_match("vendor/*", "vendor/crate"));
34426 }
34427
34428 #[test]
34429 fn wildcard_match_no_cross_prefix() {
34430 assert!(!wildcard_match("src/*.rs", "tests/foo.rs"));
34431 }
34432
34433 #[test]
34436 fn should_skip_empty_relative_is_false() {
34437 assert!(!should_skip_preview_directory("", &["vendor".to_string()]));
34438 }
34439
34440 #[test]
34441 fn should_skip_matching_pattern() {
34442 assert!(should_skip_preview_directory(
34443 "vendor",
34444 &["vendor".to_string()]
34445 ));
34446 }
34447
34448 #[test]
34449 fn should_skip_non_matching() {
34450 assert!(!should_skip_preview_directory(
34451 "src",
34452 &["vendor".to_string()]
34453 ));
34454 }
34455
34456 #[test]
34457 fn should_skip_wildcard_prefix() {
34458 assert!(should_skip_preview_directory(
34459 "target/debug",
34460 &["target*".to_string()]
34461 ));
34462 }
34463
34464 #[test]
34467 fn should_include_empty_relative_always_true() {
34468 assert!(should_include_preview_file("", &[], &[]));
34469 }
34470
34471 #[test]
34472 fn should_include_no_patterns_includes_all() {
34473 assert!(should_include_preview_file("src/main.c", &[], &[]));
34474 }
34475
34476 #[test]
34477 fn should_include_excluded_by_pattern() {
34478 assert!(!should_include_preview_file(
34479 "vendor/lib.c",
34480 &[],
34481 &["vendor/*".to_string()]
34482 ));
34483 }
34484
34485 #[test]
34486 fn should_include_include_pattern_filters() {
34487 assert!(!should_include_preview_file(
34488 "tests/test_foo.c",
34489 &["src/*".to_string()],
34490 &[]
34491 ));
34492 }
34493
34494 #[test]
34497 fn escape_html_ampersand() {
34498 assert_eq!(escape_html("a&b"), "a&b");
34499 }
34500
34501 #[test]
34502 fn escape_html_angle_brackets() {
34503 assert_eq!(escape_html("<br>"), "<br>");
34504 }
34505
34506 #[test]
34507 fn escape_html_double_quote() {
34508 assert_eq!(escape_html(r#"say "hello""#), "say "hello"");
34509 }
34510
34511 #[test]
34512 fn escape_html_single_quote() {
34513 assert_eq!(escape_html("it's"), "it's");
34514 }
34515
34516 #[test]
34517 fn escape_html_plain_text_unchanged() {
34518 assert_eq!(escape_html("hello world"), "hello world");
34519 }
34520
34521 fn make_mixed_scan_comparison() -> sloc_core::ScanComparison {
34524 sloc_core::ScanComparison {
34525 summary: sloc_core::SummaryDelta {
34526 baseline_run_id: "base".to_string(),
34527 current_run_id: "curr".to_string(),
34528 baseline_timestamp: chrono::Utc::now(),
34529 current_timestamp: chrono::Utc::now(),
34530 baseline_files: 4,
34531 current_files: 4,
34532 files_analyzed_delta: 0,
34533 baseline_code: 330,
34534 current_code: 400,
34535 code_lines_delta: 70,
34536 baseline_comments: 0,
34537 current_comments: 0,
34538 comment_lines_delta: 0,
34539 blank_lines_delta: 0,
34540 total_lines_delta: 70,
34541 coverage_lines_hit_delta: None,
34542 coverage_line_pct_delta: None,
34543 baseline_coverage_line_pct: None,
34544 current_coverage_line_pct: None,
34545 },
34546 file_deltas: vec![
34547 sloc_core::FileDelta {
34548 relative_path: "added.rs".to_string(),
34549 language: Some("Rust".to_string()),
34550 status: FileChangeStatus::Added,
34551 baseline_code: 0,
34552 current_code: 100,
34553 code_delta: 100,
34554 baseline_comment: 0,
34555 current_comment: 0,
34556 comment_delta: 0,
34557 baseline_blank: 0,
34558 current_blank: 0,
34559 blank_delta: 0,
34560 total_delta: 100,
34561 },
34562 sloc_core::FileDelta {
34563 relative_path: "removed.rs".to_string(),
34564 language: Some("Rust".to_string()),
34565 status: FileChangeStatus::Removed,
34566 baseline_code: 50,
34567 current_code: 0,
34568 code_delta: -50,
34569 baseline_comment: 0,
34570 current_comment: 0,
34571 comment_delta: 0,
34572 baseline_blank: 0,
34573 current_blank: 0,
34574 blank_delta: 0,
34575 total_delta: -50,
34576 },
34577 sloc_core::FileDelta {
34578 relative_path: "modified.rs".to_string(),
34579 language: Some("Rust".to_string()),
34580 status: FileChangeStatus::Modified,
34581 baseline_code: 80,
34582 current_code: 100,
34583 code_delta: 20,
34584 baseline_comment: 0,
34585 current_comment: 0,
34586 comment_delta: 0,
34587 baseline_blank: 0,
34588 current_blank: 0,
34589 blank_delta: 0,
34590 total_delta: 20,
34591 },
34592 sloc_core::FileDelta {
34593 relative_path: "unchanged.rs".to_string(),
34594 language: Some("Rust".to_string()),
34595 status: FileChangeStatus::Unchanged,
34596 baseline_code: 200,
34597 current_code: 200,
34598 code_delta: 0,
34599 baseline_comment: 0,
34600 current_comment: 0,
34601 comment_delta: 0,
34602 baseline_blank: 0,
34603 current_blank: 0,
34604 blank_delta: 0,
34605 total_delta: 0,
34606 },
34607 ],
34608 files_added: 1,
34609 files_removed: 1,
34610 files_modified: 1,
34611 files_unchanged: 1,
34612 files_total: 4,
34613 }
34614 }
34615
34616 #[test]
34617 fn sum_added_counts_added_and_positive_modified() {
34618 let cmp = make_mixed_scan_comparison();
34619 assert_eq!(sum_added_code_lines(&cmp), 120);
34620 }
34621
34622 #[test]
34623 fn sum_removed_counts_removed_baseline() {
34624 let cmp = make_mixed_scan_comparison();
34625 assert_eq!(sum_removed_code_lines(&cmp), 50);
34626 }
34627
34628 #[test]
34629 fn sum_unmodified_counts_unchanged_files() {
34630 let cmp = make_mixed_scan_comparison();
34631 assert_eq!(sum_unmodified_code_lines(&cmp), 200);
34632 }
34633
34634 #[test]
34637 fn detect_coverage_tool_rust_project() {
34638 let dir = tempfile::tempdir().unwrap();
34639 std::fs::write(dir.path().join("Cargo.toml"), b"[package]").unwrap();
34640 let (tool, cmd) = detect_coverage_tool(dir.path());
34641 assert_eq!(tool, Some("cargo-llvm-cov"));
34642 assert!(cmd.is_some());
34643 }
34644
34645 #[test]
34646 fn detect_coverage_tool_java_gradle() {
34647 let dir = tempfile::tempdir().unwrap();
34648 std::fs::write(dir.path().join("build.gradle"), b"apply plugin: 'java'").unwrap();
34649 let (tool, _) = detect_coverage_tool(dir.path());
34650 assert_eq!(tool, Some("jacoco"));
34651 }
34652
34653 #[test]
34654 fn detect_coverage_tool_python_pyproject() {
34655 let dir = tempfile::tempdir().unwrap();
34656 std::fs::write(dir.path().join("pyproject.toml"), b"[tool.poetry]").unwrap();
34657 let (tool, _) = detect_coverage_tool(dir.path());
34658 assert_eq!(tool, Some("pytest-cov"));
34659 }
34660
34661 #[test]
34662 fn detect_coverage_tool_unknown_project() {
34663 let dir = tempfile::tempdir().unwrap();
34664 let (tool, cmd) = detect_coverage_tool(dir.path());
34665 assert!(tool.is_none() && cmd.is_none());
34666 }
34667
34668 #[test]
34671 fn sanitize_path_str_unc_drive_stripped() {
34672 assert_eq!(sanitize_path_str("//?/C:/Users/user"), "C:/Users/user");
34673 }
34674
34675 #[test]
34676 fn sanitize_path_str_unc_network_stripped() {
34677 assert_eq!(sanitize_path_str("//?/UNC/server/share"), "//server/share");
34678 }
34679
34680 #[test]
34681 fn sanitize_path_str_plain_path_unchanged() {
34682 assert_eq!(
34683 sanitize_path_str("/home/user/project"),
34684 "/home/user/project"
34685 );
34686 }
34687
34688 #[test]
34689 fn display_path_plain_linux_unchanged() {
34690 assert_eq!(
34691 display_path(Path::new("/home/user/project")),
34692 "/home/user/project"
34693 );
34694 }
34695
34696 #[test]
34697 fn display_path_unc_drive_stripped() {
34698 let result = display_path(Path::new(r"\\?\C:\Users\user"));
34699 assert_eq!(result, r"C:\Users\user");
34700 }
34701
34702 #[test]
34703 fn display_path_unc_network_stripped() {
34704 let result = display_path(Path::new(r"\\?\UNC\server\share"));
34705 assert_eq!(result, r"\\server\share");
34706 }
34707}
34708
34709#[cfg(test)]
34710mod coverage_boost_unit_tests {
34711 use super::*;
34712 use std::path::{Path, PathBuf};
34713
34714 #[tokio::test]
34718 async fn runtime_security_config_scenarios() {
34719 unsafe { std::env::remove_var("SLOC_API_KEYS") };
34721 unsafe { std::env::remove_var("SLOC_API_KEY") };
34723 unsafe { std::env::remove_var("SLOC_TLS_CERT") };
34725 unsafe { std::env::remove_var("SLOC_TLS_KEY") };
34727 unsafe { std::env::remove_var("SLOC_TRUST_PROXY") };
34729 unsafe { std::env::remove_var("SLOC_TRUSTED_PROXY_IPS") };
34731 let cfg = load_runtime_security_config(false);
34732 assert!(cfg.api_keys.is_empty());
34733 assert!(!cfg.tls_enabled);
34734 assert!(!cfg.trust_proxy);
34735
34736 unsafe { std::env::set_var("SLOC_API_KEYS", "alpha, beta ,") };
34738 unsafe { std::env::set_var("SLOC_TRUST_PROXY", "1") };
34740 unsafe { std::env::set_var("SLOC_TRUSTED_PROXY_IPS", "127.0.0.1, 10.0.0.2") };
34742 unsafe { std::env::set_var("SLOC_RATE_LIMIT", "250") };
34744 unsafe { std::env::set_var("SLOC_AUTH_LOCKOUT_FAILS", "5") };
34746 unsafe { std::env::set_var("SLOC_AUTH_LOCKOUT_SECS", "60") };
34748 let cfg = load_runtime_security_config(true);
34749 assert_eq!(cfg.api_keys.len(), 2, "two non-empty keys parsed");
34750 assert!(cfg.trust_proxy);
34751 assert_eq!(cfg.trusted_proxy_ips.len(), 2);
34752 unsafe { std::env::remove_var("SLOC_API_KEYS") };
34754 unsafe { std::env::remove_var("SLOC_TRUST_PROXY") };
34756 unsafe { std::env::remove_var("SLOC_TRUSTED_PROXY_IPS") };
34758 unsafe { std::env::remove_var("SLOC_RATE_LIMIT") };
34760 unsafe { std::env::remove_var("SLOC_AUTH_LOCKOUT_FAILS") };
34762 unsafe { std::env::remove_var("SLOC_AUTH_LOCKOUT_SECS") };
34764 }
34765
34766 #[test]
34767 fn cors_layer_builds_both_modes() {
34768 let _ = build_cors_layer(true);
34769 let _ = build_cors_layer(false);
34770 }
34771
34772 #[test]
34773 fn primary_lan_ip_callable() {
34774 let _ = primary_lan_ip();
34776 }
34777
34778 #[test]
34779 fn safe_redirect_allows_relative_rejects_absolute() {
34780 assert_eq!(safe_redirect("/view-reports"), "/view-reports");
34781 assert_eq!(safe_redirect("https://evil.example/x"), "/");
34782 assert_eq!(safe_redirect("javascript:alert(1)"), "/");
34783 assert_eq!(default_redirect(), "/view-reports");
34784 }
34785
34786 #[test]
34787 fn tarball_size_caps_env_override() {
34788 unsafe { std::env::set_var("SLOC_MAX_TARBALL_MB", "1") };
34790 unsafe { std::env::set_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB", "2") };
34792 let (c, d) = parse_tarball_size_caps();
34793 assert_eq!(c, 1024 * 1024);
34794 assert_eq!(d, 2 * 1024 * 1024);
34795 unsafe { std::env::remove_var("SLOC_MAX_TARBALL_MB") };
34797 unsafe { std::env::remove_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB") };
34799 let (c2, _) = parse_tarball_size_caps();
34800 assert_eq!(c2, 2048 * 1024 * 1024, "default 2048 MB");
34801 }
34802
34803 #[test]
34804 fn upload_path_helpers() {
34805 let base = upload_base_dir();
34806 let staged = upload_staging_path("abc123");
34807 assert!(staged.starts_with(&base));
34808 assert!(
34809 is_upload_tmp_path(&staged),
34810 "staging path is an upload tmp path"
34811 );
34812 assert!(!is_upload_tmp_path(Path::new("/etc/passwd")));
34813 }
34814
34815 #[test]
34816 fn git_clones_dir_env_override() {
34817 unsafe { std::env::remove_var("SLOC_GIT_CLONES_DIR") };
34819 let def = resolve_git_clones_dir(Path::new("/out"));
34820 assert_eq!(def, PathBuf::from("/out").join("git-clones"));
34821 unsafe { std::env::set_var("SLOC_GIT_CLONES_DIR", "/custom/clones") };
34823 assert_eq!(
34824 resolve_git_clones_dir(Path::new("/out")),
34825 PathBuf::from("/custom/clones")
34826 );
34827 unsafe { std::env::remove_var("SLOC_GIT_CLONES_DIR") };
34829 }
34830
34831 #[test]
34832 fn html_report_file_detection() {
34833 let dir = std::env::temp_dir().join("sloc_html_detect");
34834 let _ = std::fs::create_dir_all(&dir);
34835 let good = dir.join("report_x.html");
34836 std::fs::write(&good, "<html></html>").unwrap();
34837 let bad = dir.join("notes.txt");
34838 std::fs::write(&bad, "x").unwrap();
34839 assert!(is_html_report_file(&good));
34840 assert!(!is_html_report_file(&bad));
34841 assert!(find_html_report_in_dir(&dir).is_some());
34842 let _ = std::fs::remove_dir_all(&dir);
34843 }
34844
34845 #[test]
34846 fn multi_delta_class_and_format() {
34847 assert_eq!(multi_delta_class(5), "pos");
34848 assert_eq!(multi_delta_class(-5), "neg");
34849 assert_eq!(multi_delta_class(0), "zero");
34850 assert_eq!(multi_fmt_delta(3), "+3");
34851 assert_eq!(multi_fmt_delta(-3), "-3");
34852 assert_eq!(multi_fmt_delta(0), "0");
34853 }
34854
34855 #[test]
34856 fn git_clone_dest_sanitizes() {
34857 let dest = git_clone_dest("https://github.com/org/repo.git", Path::new("/clones"));
34858 assert!(dest.starts_with("/clones"));
34859 let name = dest.file_name().unwrap().to_str().unwrap();
34860 assert!(
34861 name.chars()
34862 .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'))
34863 );
34864 }
34865}
34866
34867#[cfg(test)]
34868mod tests_private {
34869 use super::*;
34870 use std::io::Read;
34871
34872 #[test]
34875 fn local_mode_never_refuses_start() {
34876 assert!(!refuse_unauthenticated_server(false, false));
34878 assert!(!refuse_unauthenticated_server(false, true));
34879 }
34880
34881 #[test]
34882 fn server_mode_with_key_is_allowed() {
34883 assert!(!refuse_unauthenticated_server(true, true));
34884 }
34885
34886 #[test]
34889 fn server_mode_auth_gate_respects_optin() {
34890 unsafe { std::env::remove_var("SLOC_ALLOW_UNAUTHENTICATED") };
34892 assert!(
34893 refuse_unauthenticated_server(true, false),
34894 "server mode + no key must fail closed by default"
34895 );
34896 unsafe { std::env::set_var("SLOC_ALLOW_UNAUTHENTICATED", "1") };
34898 assert!(
34899 !refuse_unauthenticated_server(true, false),
34900 "explicit opt-in must allow the unauthenticated server"
34901 );
34902 unsafe { std::env::remove_var("SLOC_ALLOW_UNAUTHENTICATED") };
34904 }
34905
34906 fn raw_tar_block(name: &str, data: &[u8]) -> Vec<u8> {
34912 let mut h = [0u8; 512];
34913 let nb = name.as_bytes();
34914 h[..nb.len()].copy_from_slice(nb);
34915 h[100..108].copy_from_slice(b"0000644\0");
34916 h[108..116].copy_from_slice(b"0000000\0");
34917 h[116..124].copy_from_slice(b"0000000\0");
34918 h[124..136].copy_from_slice(format!("{:011o}\0", data.len()).as_bytes());
34919 h[136..148].copy_from_slice(b"00000000000\0");
34920 h[156] = b'0'; h[257..263].copy_from_slice(b"ustar\0");
34922 h[263..265].copy_from_slice(b"00");
34923 for b in &mut h[148..156] {
34924 *b = b' ';
34925 }
34926 let sum: u32 = h.iter().map(|&b| u32::from(b)).sum();
34927 h[148..156].copy_from_slice(format!("{sum:06o}\0 ").as_bytes());
34928
34929 let mut out = h.to_vec();
34930 out.extend_from_slice(data);
34931 out.resize(out.len() + (512 - data.len() % 512) % 512, 0); out.resize(out.len() + 1024, 0); out
34934 }
34935
34936 #[tokio::test]
34940 async fn tarball_extraction_blocks_zip_slip() {
34941 use std::io::Write as _;
34942
34943 let base = std::env::temp_dir().join(format!("sloc_zipslip_{}", uuid::Uuid::new_v4()));
34944 let staging = base.join("staging");
34945 let tar_gz = base.join("evil.tar.gz");
34946 std::fs::create_dir_all(&base).unwrap();
34947
34948 {
34950 let f = std::fs::File::create(&tar_gz).unwrap();
34951 let mut enc = flate2::write::GzEncoder::new(f, flate2::Compression::default());
34952 enc.write_all(&raw_tar_block("../escaped.txt", b"pwned"))
34953 .unwrap();
34954 enc.finish().unwrap().flush().unwrap();
34955 }
34956
34957 let _ = extract_tarball_to_staging(&tar_gz, &staging, 10 * 1024 * 1024).await;
34959
34960 let escaped = base.join("escaped.txt");
34961 assert!(
34962 !escaped.exists(),
34963 "zip-slip entry escaped staging to {}",
34964 escaped.display()
34965 );
34966
34967 let _ = std::fs::remove_dir_all(&base);
34968 }
34969
34970 #[test]
34971 fn size_limit_reader_zero_remaining_returns_error() {
34972 let data = b"hello world";
34973 let mut reader = SizeLimitReader {
34974 inner: &data[..],
34975 remaining: 0,
34976 };
34977 let mut buf = [0u8; 4];
34978 assert!(reader.read(&mut buf).is_err());
34979 }
34980
34981 #[test]
34982 fn size_limit_reader_counts_bytes() {
34983 let data = b"hello world";
34984 let mut reader = SizeLimitReader {
34985 inner: &data[..],
34986 remaining: 5,
34987 };
34988 let mut buf = [0u8; 4];
34989 let n = reader.read(&mut buf).unwrap();
34990 assert_eq!(n, 4);
34991 assert_eq!(reader.remaining, 1);
34992 }
34993
34994 #[test]
34995 fn resolve_or_create_staging_with_valid_uuid_reuses_id() {
34996 let uuid = "12345678-1234-1234-1234-123456789012";
34997 let (id, path) = resolve_or_create_staging(Some(uuid));
34998 assert_eq!(id, uuid);
34999 assert!(path.to_string_lossy().contains("oxide-sloc-uploads"));
35000 }
35001
35002 #[test]
35003 fn resolve_or_create_staging_with_none_creates_new() {
35004 let (id1, _) = resolve_or_create_staging(None);
35005 let (id2, _) = resolve_or_create_staging(None);
35006 assert_ne!(id1, id2);
35007 }
35008
35009 #[test]
35010 fn resolve_or_create_staging_with_path_separator_creates_new() {
35011 let (id, _) = resolve_or_create_staging(Some("has/slash"));
35013 assert_ne!(id, "has/slash");
35014 }
35015
35016 #[test]
35017 fn auth_lockout_remaining_secs_no_entry_returns_zero() {
35018 use std::net::IpAddr;
35019 use std::str::FromStr;
35020 let limiter = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_mins(5));
35021 let ip = IpAddr::from_str("192.168.1.1").unwrap();
35022 assert_eq!(limiter.auth_lockout_remaining_secs(ip), 0);
35023 }
35024
35025 #[test]
35026 fn is_auth_locked_out_expired_entry_removed() {
35027 use std::net::IpAddr;
35028 use std::str::FromStr;
35029 let limiter = IpRateLimiter::new(
35030 Duration::from_mins(1),
35031 100,
35032 1, Duration::from_millis(1),
35034 );
35035 let ip = IpAddr::from_str("192.168.1.2").unwrap();
35036 limiter.record_auth_failure(ip);
35037 std::thread::sleep(Duration::from_millis(10));
35039 assert!(!limiter.is_auth_locked_out(ip));
35041 }
35042
35043 #[test]
35044 fn is_auth_locked_out_within_window_returns_true() {
35045 use std::net::IpAddr;
35046 use std::str::FromStr;
35047 let limiter = IpRateLimiter::new(
35048 Duration::from_mins(1),
35049 100,
35050 2, Duration::from_hours(1),
35052 );
35053 let ip = IpAddr::from_str("192.168.1.3").unwrap();
35054 limiter.record_auth_failure(ip);
35055 limiter.record_auth_failure(ip);
35056 assert!(limiter.is_auth_locked_out(ip));
35057 }
35058
35059 #[test]
35062 fn output_folder_hint_strips_json_subdir() {
35063 use std::path::Path;
35064 let path = Path::new("/output/scan1/json/result.json");
35065 let hint = output_folder_hint(path);
35066 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
35067 }
35068
35069 #[test]
35070 fn output_folder_hint_strips_html_subdir() {
35071 use std::path::Path;
35072 let path = Path::new("/output/scan1/html/report.html");
35073 let hint = output_folder_hint(path);
35074 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
35075 }
35076
35077 #[test]
35078 fn output_folder_hint_strips_pdf_subdir() {
35079 use std::path::Path;
35080 let path = Path::new("/output/scan1/pdf/report.pdf");
35081 let hint = output_folder_hint(path);
35082 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
35083 }
35084
35085 #[test]
35086 fn output_folder_hint_strips_excel_subdir() {
35087 use std::path::Path;
35088 let path = Path::new("/output/scan1/excel/report.xlsx");
35089 let hint = output_folder_hint(path);
35090 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
35091 }
35092
35093 #[test]
35094 fn output_folder_hint_flat_layout_returns_direct_parent() {
35095 use std::path::Path;
35096 let path = Path::new("/output/scan1/result.json");
35097 let hint = output_folder_hint(path);
35098 assert!(
35099 hint.ends_with("scan1"),
35100 "expected direct parent, got: {hint}"
35101 );
35102 }
35103
35104 #[test]
35105 fn output_folder_hint_other_subdir_name_not_stripped() {
35106 use std::path::Path;
35107 let path = Path::new("/output/scan1/data/result.json");
35109 let hint = output_folder_hint(path);
35110 assert!(
35111 hint.ends_with("data"),
35112 "non-artifact subdir must not be stripped, got: {hint}"
35113 );
35114 }
35115
35116 #[test]
35119 fn find_file_by_ext_finds_matching_file() {
35120 let dir = std::env::temp_dir().join("sloc_web_fbe_test");
35121 let _ = fs::create_dir_all(&dir);
35122 let f = dir.join("report.pdf");
35123 let _ = fs::write(&f, b"dummy");
35124 let result = find_file_by_ext(&dir, "pdf");
35125 assert!(result.is_some(), "expected to find report.pdf");
35126 let _ = fs::remove_dir_all(&dir);
35127 }
35128
35129 #[test]
35130 fn find_file_by_ext_returns_none_for_missing_ext() {
35131 let dir = std::env::temp_dir().join("sloc_web_fbe_test2");
35132 let _ = fs::create_dir_all(&dir);
35133 let f = dir.join("report.json");
35134 let _ = fs::write(&f, b"{}");
35135 let result = find_file_by_ext(&dir, "pdf");
35136 assert!(result.is_none());
35137 let _ = fs::remove_dir_all(&dir);
35138 }
35139
35140 #[test]
35141 fn find_file_by_ext_returns_none_for_nonexistent_dir() {
35142 let dir = std::path::Path::new("/nonexistent/dir/that/does/not/exist");
35143 assert!(find_file_by_ext(dir, "json").is_none());
35144 }
35145
35146 #[test]
35149 fn collect_result_json_candidates_flat_root() {
35150 let root = std::env::temp_dir().join("sloc_web_crjc_flat");
35151 let _ = fs::create_dir_all(&root);
35152 let _ = fs::write(root.join("result.json"), b"{}");
35153 let candidates = collect_result_json_candidates(&root);
35154 assert!(!candidates.is_empty(), "should find result.json at root");
35155 let _ = fs::remove_dir_all(&root);
35156 }
35157
35158 #[test]
35159 fn collect_result_json_candidates_legacy_subdir() {
35160 let root = std::env::temp_dir().join("sloc_web_crjc_legacy");
35161 let sub = root.join("scanA");
35162 let _ = fs::create_dir_all(&sub);
35163 let _ = fs::write(sub.join("result.json"), b"{}");
35164 let candidates = collect_result_json_candidates(&root);
35165 assert!(
35166 !candidates.is_empty(),
35167 "should find result.json in legacy subdir"
35168 );
35169 let _ = fs::remove_dir_all(&root);
35170 }
35171
35172 #[test]
35173 fn collect_result_json_candidates_structured_json_subdir() {
35174 let root = std::env::temp_dir().join("sloc_web_crjc_struct");
35175 let json_sub = root.join("scanB").join("json");
35176 let _ = fs::create_dir_all(&json_sub);
35177 let _ = fs::write(json_sub.join("result.json"), b"{}");
35178 let candidates = collect_result_json_candidates(&root);
35179 assert!(
35180 !candidates.is_empty(),
35181 "should find result.json inside <subdir>/json/"
35182 );
35183 let _ = fs::remove_dir_all(&root);
35184 }
35185
35186 #[test]
35187 fn collect_result_json_candidates_empty_dir() {
35188 let root = std::env::temp_dir().join("sloc_web_crjc_empty");
35189 let _ = fs::create_dir_all(&root);
35190 let candidates = collect_result_json_candidates(&root);
35191 assert!(candidates.is_empty());
35192 let _ = fs::remove_dir_all(&root);
35193 }
35194}