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 connectivity;
30pub(crate) mod error;
31pub(crate) mod git_browser;
32pub(crate) mod git_webhook;
33pub(crate) mod integrations;
34pub(crate) mod report_bug;
35
36use std::{
37 collections::{HashMap, VecDeque},
38 fmt::Write,
39 fs,
40 net::{IpAddr, SocketAddr, ToSocketAddrs},
41 path::{Path, PathBuf},
42 process::Stdio,
43 sync::{Arc, OnceLock},
44 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
45};
46
47use anyhow::{Context, Result};
48use askama::Template;
49use axum::{
50 Json, Router,
51 body::Body,
52 extract::{DefaultBodyLimit, Form, Path as AxumPath, Query, State},
53 http::{HeaderValue, Request, StatusCode, header},
54 middleware::{self, Next},
55 response::{Html, IntoResponse, Response},
56 routing::{get, post},
57};
58use serde::{Deserialize, Serialize};
59use tokio::sync::Mutex;
60use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
61
62use sloc_config::{
63 AppConfig, BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy,
64 MixedLinePolicy,
65};
66use sloc_git::ScheduleStore;
67
68#[derive(Clone)]
69pub(crate) struct CspNonce(pub(crate) String);
70
71static CHART_JS: &[u8] = include_bytes!("../static/chart.umd.min.js");
72static APP_CSS: &str = include_str!("../static/app.css");
73static APP_JS: &str = include_str!("../static/app.js");
74static REPORT_CHART_JS: &[u8] = include_bytes!("../static/chart.min.js");
75
76use sloc_core::{
77 AnalysisRun, Author, AuthorMergeGroup, CleanupPolicy, CleanupPolicyStore, FileChangeStatus,
78 IdentityMap, MultiScanComparison, RegistryEntry, ScanRegistry, ScanSummarySnapshot,
79 SummaryTotals, WatchedDirsStore, analyze, apply_identity_map, auto_merge_noreply_identities,
80 compute_delta, compute_multi_delta, read_json,
81};
82use sloc_report::{
83 ReportDeltaContext, render_html, render_html_with_delta, render_sub_report_html,
84 write_pdf_from_html, write_pdf_from_run,
85};
86const MAX_CONCURRENT_ANALYSES: usize = 4;
87
88#[cfg(target_os = "windows")]
96#[allow(clippy::upper_case_acronyms)]
97#[allow(dead_code)]
98mod win_dialog_focus {
99 #[cfg(feature = "native-dialog")]
100 use std::mem::size_of;
101
102 type HWND = *mut core::ffi::c_void;
103 type DWORD = u32;
104 type UINT = u32;
105 type BOOL = i32;
106
107 #[cfg(feature = "native-dialog")]
109 #[repr(C)]
110 #[allow(non_snake_case)]
111 struct FLASHWINFO {
112 cbSize: UINT,
113 hwnd: HWND,
114 dwFlags: DWORD,
115 uCount: UINT,
116 dwTimeout: DWORD,
117 }
118
119 #[cfg(feature = "native-dialog")]
120 const FLASHW_ALL: DWORD = 0x3;
121 #[cfg(feature = "native-dialog")]
122 const FLASHW_TIMERNOFG: DWORD = 0xC;
123
124 #[link(name = "user32")]
125 unsafe extern "system" {
126 fn GetForegroundWindow() -> HWND;
127 fn SetForegroundWindow(hWnd: HWND) -> BOOL;
128 fn ShowWindow(hWnd: HWND, nCmdShow: i32) -> BOOL;
129 fn BringWindowToTop(hWnd: HWND) -> BOOL;
130 fn SetWindowPos(
131 hWnd: HWND,
132 hWndAfter: HWND,
133 x: i32,
134 y: i32,
135 cx: i32,
136 cy: i32,
137 flags: UINT,
138 ) -> BOOL;
139 fn GetWindowThreadProcessId(hWnd: HWND, lpdwProcessId: *mut DWORD) -> DWORD;
140 fn AttachThreadInput(idAttach: DWORD, idAttachTo: DWORD, fAttach: BOOL) -> BOOL;
141 fn keybd_event(bVk: u8, bScan: u8, dwFlags: DWORD, dwExtraInfo: usize);
144 #[cfg(feature = "native-dialog")]
145 fn FlashWindowEx(pfwi: *const FLASHWINFO) -> BOOL;
146 fn FindWindowW(lpClassName: *const u16, lpWindowName: *const u16) -> HWND;
147 fn FindWindowExW(
148 hWndParent: HWND,
149 hWndChildAfter: HWND,
150 lpszClass: *const u16,
151 lpszWindow: *const u16,
152 ) -> HWND;
153 fn SwitchToThisWindow(hWnd: HWND, fAltTab: BOOL);
157 }
158
159 #[link(name = "kernel32")]
160 unsafe extern "system" {
161 fn GetCurrentThreadId() -> DWORD;
162 }
163
164 #[link(name = "shell32")]
165 unsafe extern "system" {
166 fn ShellExecuteW(
171 hwnd: HWND,
172 lpOperation: *const u16,
173 lpFile: *const u16,
174 lpParameters: *const u16,
175 lpDirectory: *const u16,
176 nShowCmd: i32,
177 ) -> isize; }
179
180 #[cfg(feature = "native-dialog")]
185 pub fn attach_to_foreground() -> DWORD {
186 unsafe {
187 let fg_hwnd = GetForegroundWindow();
188 if fg_hwnd.is_null() {
189 return 0;
190 }
191 let fg_tid = GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut());
192 let my_tid = GetCurrentThreadId();
193 if fg_tid == my_tid {
194 return 0;
195 }
196 AttachThreadInput(my_tid, fg_tid, 1);
197 fg_tid
198 }
199 }
200
201 #[cfg(feature = "native-dialog")]
203 pub fn detach_from_foreground(fg_tid: DWORD) {
204 if fg_tid == 0 {
205 return;
206 }
207 unsafe {
208 AttachThreadInput(GetCurrentThreadId(), fg_tid, 0);
209 }
210 }
211
212 unsafe fn snapshot_explorer_hwnds(class_w: &[u16]) -> std::collections::HashSet<usize> {
213 unsafe {
214 let mut existing = std::collections::HashSet::new();
215 let mut prev: HWND = core::ptr::null_mut();
216 loop {
217 let w = FindWindowExW(
218 core::ptr::null_mut(),
219 prev,
220 class_w.as_ptr(),
221 core::ptr::null(),
222 );
223 if w.is_null() {
224 break;
225 }
226 existing.insert(w as usize);
227 prev = w;
228 }
229 existing
230 }
231 }
232
233 unsafe fn find_new_explorer_hwnd(
234 class_w: &[u16],
235 existing: &std::collections::HashSet<usize>,
236 ) -> Option<HWND> {
237 unsafe {
238 let mut prev: HWND = core::ptr::null_mut();
239 loop {
240 let w = FindWindowExW(
241 core::ptr::null_mut(),
242 prev,
243 class_w.as_ptr(),
244 core::ptr::null(),
245 );
246 if w.is_null() {
247 return None;
248 }
249 if !existing.contains(&(w as usize)) {
250 return Some(w);
251 }
252 prev = w;
253 }
254 }
255 }
256
257 unsafe fn bring_to_front(hwnd: HWND) {
258 unsafe {
259 let my_tid = GetCurrentThreadId();
267 let fg_hwnd = GetForegroundWindow();
268 let fg_tid = if fg_hwnd.is_null() {
269 0
270 } else {
271 GetWindowThreadProcessId(fg_hwnd, core::ptr::null_mut())
272 };
273 let attached =
274 fg_tid != 0 && fg_tid != my_tid && AttachThreadInput(my_tid, fg_tid, 1) != 0;
275
276 keybd_event(0x12, 0, 0, 0);
285 keybd_event(0x12, 0, 0x0002, 0);
286
287 ShowWindow(hwnd, 9);
290 BringWindowToTop(hwnd);
291 SetForegroundWindow(hwnd);
292 SwitchToThisWindow(hwnd, 1);
295
296 SetWindowPos(hwnd, (-1isize) as HWND, 0, 0, 0, 0, 0x0013);
301 SetWindowPos(hwnd, (-2isize) as HWND, 0, 0, 0, 0, 0x0013);
302
303 if attached {
304 AttachThreadInput(my_tid, fg_tid, 0);
305 }
306 }
307 }
308
309 pub fn open_folder_foreground(path: std::path::PathBuf) {
316 std::thread::spawn(move || {
317 use std::os::windows::ffi::OsStrExt;
318
319 let op: Vec<u16> = "explore\0".encode_utf16().collect();
320 let mut path_w: Vec<u16> = path.as_os_str().encode_wide().collect();
321 path_w.push(0);
322 let class_w: Vec<u16> = "CabinetWClass\0".encode_utf16().collect();
323
324 unsafe {
325 let existing = snapshot_explorer_hwnds(&class_w);
328 let fg_hwnd = GetForegroundWindow();
329 ShellExecuteW(
331 fg_hwnd,
332 op.as_ptr(),
333 path_w.as_ptr(),
334 core::ptr::null(),
335 core::ptr::null(),
336 1,
337 );
338
339 for _ in 0..40 {
343 std::thread::sleep(std::time::Duration::from_millis(75));
344 if let Some(w) = find_new_explorer_hwnd(&class_w, &existing) {
345 bring_to_front(w);
346 return;
347 }
348 }
349
350 let w = FindWindowW(class_w.as_ptr(), core::ptr::null());
353 if !w.is_null() {
354 bring_to_front(w);
355 }
356 }
357 });
358 }
359
360 #[cfg(feature = "native-dialog")]
364 pub fn flash_dialog_when_ready(title: String) {
365 std::thread::spawn(move || {
366 let title_w: Vec<u16> = title.encode_utf16().chain(core::iter::once(0)).collect();
367 for _ in 0..40 {
368 std::thread::sleep(std::time::Duration::from_millis(80));
369 unsafe {
370 let hwnd = FindWindowW(core::ptr::null(), title_w.as_ptr());
371 if !hwnd.is_null() {
372 SetForegroundWindow(hwnd);
373 BringWindowToTop(hwnd);
374 #[allow(non_snake_case)]
375 FlashWindowEx(&FLASHWINFO {
376 #[allow(clippy::cast_possible_truncation)]
379 cbSize: size_of::<FLASHWINFO>() as UINT,
380 hwnd,
381 dwFlags: FLASHW_ALL | FLASHW_TIMERNOFG,
382 uCount: 3,
383 dwTimeout: 0,
384 });
385 break;
386 }
387 }
388 }
389 });
390 }
391}
392
393pub(crate) struct IpRateLimiter {
396 window: Duration,
397 max_requests: usize,
398 pub(crate) auth_lockout_threshold: u32,
399 auth_lockout_window: Duration,
400 state: std::sync::Mutex<HashMap<IpAddr, VecDeque<Instant>>>,
401 auth_failures: std::sync::Mutex<HashMap<IpAddr, (u32, Instant)>>,
402}
403
404impl IpRateLimiter {
405 pub(crate) fn new(
406 window: Duration,
407 max_requests: usize,
408 auth_lockout_threshold: u32,
409 auth_lockout_window: Duration,
410 ) -> Self {
411 Self {
412 window,
413 max_requests,
414 auth_lockout_threshold,
415 auth_lockout_window,
416 state: std::sync::Mutex::new(HashMap::new()),
417 auth_failures: std::sync::Mutex::new(HashMap::new()),
418 }
419 }
420
421 #[allow(clippy::significant_drop_tightening)]
424 pub(crate) fn is_allowed(&self, ip: IpAddr) -> bool {
425 let now = Instant::now();
426 let cutoff = now.checked_sub(self.window).unwrap_or(now);
427 let mut state = self
428 .state
429 .lock()
430 .unwrap_or_else(std::sync::PoisonError::into_inner);
431 if state.len() > 10_000 {
432 state.retain(|_, bucket| {
433 while bucket.front().is_some_and(|t| *t <= cutoff) {
434 bucket.pop_front();
435 }
436 !bucket.is_empty()
437 });
438 }
439 let bucket = state.entry(ip).or_default();
440 while bucket.front().is_some_and(|t| *t <= cutoff) {
441 bucket.pop_front();
442 }
443 if bucket.len() >= self.max_requests {
444 false
445 } else {
446 bucket.push_back(now);
447 true
448 }
449 }
450
451 pub(crate) fn record_auth_failure(&self, ip: IpAddr) {
452 let now = Instant::now();
453 let mut map = self
454 .auth_failures
455 .lock()
456 .unwrap_or_else(std::sync::PoisonError::into_inner);
457 map.entry(ip)
458 .and_modify(|e| {
459 e.0 += 1;
460 e.1 = now;
461 })
462 .or_insert_with(|| (1, now));
463 }
464
465 pub(crate) fn is_auth_locked_out(&self, ip: IpAddr) -> bool {
466 let mut map = self
467 .auth_failures
468 .lock()
469 .unwrap_or_else(std::sync::PoisonError::into_inner);
470 let expired = map
471 .get(&ip)
472 .is_some_and(|e| e.1.elapsed() > self.auth_lockout_window);
473 if expired {
474 map.remove(&ip);
475 return false;
476 }
477 map.get(&ip)
478 .is_some_and(|e| e.0 >= self.auth_lockout_threshold)
479 }
480
481 pub(crate) fn auth_lockout_remaining_secs(&self, ip: IpAddr) -> u64 {
482 let map = self
483 .auth_failures
484 .lock()
485 .unwrap_or_else(std::sync::PoisonError::into_inner);
486 map.get(&ip).map_or(0, |e| {
487 self.auth_lockout_window
488 .checked_sub(e.1.elapsed())
489 .map_or(0, |r| r.as_secs())
490 })
491 }
492
493 pub(crate) fn spawn_pruning_task(limiter: Arc<Self>) {
494 tokio::spawn(async move {
495 let mut interval = tokio::time::interval(Duration::from_mins(1));
496 interval.tick().await; loop {
498 interval.tick().await;
499 let now = Instant::now();
500 let cutoff = now.checked_sub(limiter.window).unwrap_or(now);
501 {
502 let mut state = limiter
503 .state
504 .lock()
505 .unwrap_or_else(std::sync::PoisonError::into_inner);
506 state.retain(|_, bucket| {
507 while bucket.front().is_some_and(|t| *t <= cutoff) {
508 bucket.pop_front();
509 }
510 !bucket.is_empty()
511 });
512 }
513 {
514 let mut auth = limiter
515 .auth_failures
516 .lock()
517 .unwrap_or_else(std::sync::PoisonError::into_inner);
518 auth.retain(|_, e| e.1.elapsed() <= limiter.auth_lockout_window);
519 }
520 }
521 });
522 }
523}
524
525fn spawn_upload_staging_cleanup() {
529 tokio::spawn(async move {
530 let ttl_hours: u64 = std::env::var("SLOC_UPLOAD_TTL_HOURS")
531 .ok()
532 .and_then(|v| v.parse().ok())
533 .unwrap_or(4);
534 let ttl_secs = ttl_hours * 3600;
535 let mut interval = tokio::time::interval(Duration::from_hours(1));
536 interval.tick().await; loop {
538 interval.tick().await;
539 let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
540 let Ok(mut dir) = tokio::fs::read_dir(&upload_root).await else {
541 continue;
542 };
543 while let Ok(Some(entry)) = dir.next_entry().await {
544 let path = entry.path();
545 let age_secs = tokio::fs::metadata(&path)
546 .await
547 .ok()
548 .and_then(|m| m.modified().ok())
549 .and_then(|t| t.elapsed().ok())
550 .map_or(0, |d| d.as_secs());
551 if age_secs > ttl_secs {
552 tracing::debug!(
553 event = "upload_staging_cleanup",
554 path = %path.display(),
555 age_secs,
556 "removing stale upload staging directory"
557 );
558 let _ = tokio::fs::remove_dir_all(&path).await;
559 }
560 }
561 }
562 });
563}
564
565#[derive(Clone, Debug, Default)]
567struct RunResultContext {
568 prev_entry: Option<RegistryEntry>,
569 prev_scan_count: usize,
570 project_path: String,
571 cocomo_mode: String,
573 complexity_alert: u32,
575 #[allow(dead_code)]
577 exclude_duplicates: bool,
578}
579
580#[derive(Clone)]
582enum AsyncRunState {
583 Running {
584 started_at: std::time::Instant,
585 cancel_token: Arc<std::sync::atomic::AtomicBool>,
586 phase: Arc<std::sync::Mutex<String>>,
587 files_done: Arc<std::sync::atomic::AtomicUsize>,
588 files_total: Arc<std::sync::atomic::AtomicUsize>,
589 attrib_done: Arc<std::sync::atomic::AtomicUsize>,
590 attrib_total: Arc<std::sync::atomic::AtomicUsize>,
591 },
592 Complete {
594 run_id: String,
595 },
596 Failed {
597 message: String,
598 },
599 Cancelled,
600}
601
602#[derive(Debug, Clone, Serialize, Deserialize)]
605struct ScanProfile {
606 id: String,
607 name: String,
608 created_at: String,
609 params: serde_json::Value,
611}
612
613#[derive(Debug, Clone, Default, Serialize, Deserialize)]
614struct ScanProfileStore {
615 profiles: Vec<ScanProfile>,
616}
617
618impl ScanProfileStore {
619 fn load(path: &std::path::Path) -> Self {
620 fs::read_to_string(path)
621 .ok()
622 .and_then(|s| serde_json::from_str(&s).ok())
623 .unwrap_or_default()
624 }
625
626 fn save(&self, path: &std::path::Path) -> anyhow::Result<()> {
627 let path = sloc_core::reject_traversal(path)?;
628 if let Some(parent) = path.parent() {
629 fs::create_dir_all(parent)?;
630 }
631 let json = serde_json::to_string_pretty(self)?;
632 fs::write(&path, json)?;
633 Ok(())
634 }
635}
636
637#[derive(Clone, Copy)]
640pub(crate) struct SessionState {
641 pub(crate) absolute_expiry: Instant,
642 pub(crate) last_seen: Instant,
643}
644
645#[allow(clippy::struct_excessive_bools)]
651#[derive(Clone)]
652pub(crate) struct AppState {
653 pub(crate) base_config: AppConfig,
654 pub(crate) artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
655 pub(crate) async_runs: Arc<Mutex<HashMap<String, AsyncRunState>>>,
656 pub(crate) registry: Arc<Mutex<ScanRegistry>>,
657 pub(crate) registry_path: PathBuf,
658 pub(crate) analyze_semaphore: Arc<tokio::sync::Semaphore>,
659 pub(crate) server_mode: bool,
660 pub(crate) allow_unauthenticated: bool,
664 pub(crate) tls_enabled: bool,
665 pub(crate) api_keys: Arc<Vec<secrecy::SecretBox<String>>>,
666 pub(crate) readonly_api_keys: Arc<Vec<secrecy::SecretBox<String>>>,
670 pub(crate) rate_limiter: Arc<IpRateLimiter>,
671 pub(crate) trust_proxy: bool,
672 pub(crate) trusted_proxy_ips: Vec<IpAddr>,
675 pub(crate) git_clones_dir: PathBuf,
677 pub(crate) schedules: Arc<Mutex<ScheduleStore>>,
679 pub(crate) schedules_path: PathBuf,
680 pub(crate) scan_profiles: Arc<Mutex<ScanProfileStore>>,
682 pub(crate) scan_profiles_path: PathBuf,
683 pub(crate) sessions: Arc<std::sync::Mutex<HashMap<String, SessionState>>>,
684 pub(crate) confluence: Arc<Mutex<confluence::ConfluenceConfigStore>>,
686 pub(crate) confluence_path: PathBuf,
687 pub(crate) watched_dirs: Arc<Mutex<WatchedDirsStore>>,
689 pub(crate) watched_dirs_path: PathBuf,
690 pub(crate) cleanup_policy: Arc<Mutex<CleanupPolicyStore>>,
692 pub(crate) cleanup_policy_path: PathBuf,
693 pub(crate) cleanup_task_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
695}
696
697type PendingPdf = Option<(PathBuf, PathBuf, bool)>;
698
699#[derive(Clone, Debug)]
702pub(crate) struct RunArtifacts {
703 output_dir: PathBuf,
704 html_path: Option<PathBuf>,
705 pdf_path: Option<PathBuf>,
706 json_path: Option<PathBuf>,
707 csv_path: Option<PathBuf>,
708 xlsx_path: Option<PathBuf>,
709 scan_config_path: Option<PathBuf>,
710 report_title: String,
711 result_context: RunResultContext,
712}
713
714const OWNERSHIP_PALETTE: &[&str] = &[
717 "#C45C10", "#2A6846", "#4472C4", "#805099", "#D4A017", "#B23030", "#2E75B6", "#70AD47",
718 "#FF9900", "#9E480E", "#636363", "#156082", "#D0743C", "#5BA8A0",
719];
720
721struct OwnershipRow {
723 name: String,
724 email: String,
725 code: u64,
726 comment: u64,
727 blank: u64,
728 total: u64,
729 code_pct: f64,
730 files_owned: u64,
731 aliases: usize,
732 color: &'static str,
733 profile: Option<String>,
736 test_code: u64,
739 test_comment: u64,
740 test_blank: u64,
741 test_total: u64,
742}
743
744fn url_query_encode(s: &str) -> String {
746 let mut out = String::with_capacity(s.len());
747 for b in s.bytes() {
748 match b {
749 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
750 out.push(b as char);
751 }
752 _ => out.push_str(&format!("%{b:02X}")),
753 }
754 }
755 out
756}
757
758fn split_git_remote(r: &str) -> Option<(String, String)> {
761 if let Some(rest) = r
762 .strip_prefix("https://")
763 .or_else(|| r.strip_prefix("http://"))
764 {
765 let (authority, path) = rest.split_once('/')?;
766 let host = authority.rsplit('@').next().unwrap_or(authority);
767 return Some((host.to_string(), path.to_string()));
768 }
769 if let Some(rest) = r.strip_prefix("ssh://") {
770 let rest = rest.rsplit('@').next().unwrap_or(rest);
771 let (host, path) = rest.split_once('/')?;
772 return Some((host.to_string(), path.to_string()));
773 }
774 if let Some(rest) = r.strip_prefix("git@") {
775 let (host, path) = rest.split_once(':')?;
776 return Some((host.to_string(), path.to_string()));
777 }
778 None
779}
780
781fn parse_remote_host_slug(remote: &str) -> Option<(String, String)> {
785 let (host, path) = split_git_remote(remote.trim())?;
786 let path = path.trim().trim_end_matches('/');
787 let path = path.strip_suffix(".git").unwrap_or(path);
788 if host.is_empty() || !path.contains('/') {
789 return None;
790 }
791 Some((host, path.to_string()))
792}
793
794fn author_profile_url(remote_url: Option<&str>, name: &str, email: &str) -> Option<String> {
800 if let Some(local) = email.strip_suffix("@users.noreply.github.com") {
801 let login = local.rsplit('+').next().unwrap_or(local);
802 if !login.is_empty() {
803 return Some(format!("https://github.com/{login}"));
804 }
805 }
806 let (host, slug) = parse_remote_host_slug(remote_url?)?;
807 let on = |domain: &str| host == domain || host.ends_with(&format!(".{domain}"));
808 let email_q = url_query_encode(email);
809 let name_q = url_query_encode(name);
810 if on("github.com") {
811 Some(format!("https://{host}/{slug}/commits?author={email_q}"))
812 } else if on("gitlab.com") {
813 Some(format!("https://{host}/{slug}/-/commits?author={name_q}"))
814 } else if on("bitbucket.org") {
815 Some(format!("https://{host}/{slug}/commits/?author={email_q}"))
816 } else {
817 None
818 }
819}
820
821fn ownership_page_css() -> &'static str {
824 r#":root{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.82);--surface-2:#fbf7f2;--line:#e6d0bf;--line-strong:#d8bfad;--text:#43342d;--muted:#7b675b;--muted-2:#a08878;--nav:#283790;--nav-2:#013e6b;--accent:#6f9bff;--oxide:#d37a4c;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}
825body.dark-theme{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;--line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;--muted-2:#9c877a;}
826*{box-sizing:border-box;} 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);} body{display:flex;flex-direction:column;}
827.background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
828.background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
829.code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
830.code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
831@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));}}
832.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);}
833.top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
834.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));}
835.brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
836.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;}
837.nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
838@media (max-width:1150px){.brand-subtitle{display:none;}}
839.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;}
840.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
841.theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;} .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
842.theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
843.theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
844.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;}
845.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-wrap:hover .server-status-tip{display:block;}
846.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:175px;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;}
847.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:240px;max-width:300px;opacity:0;pointer-events:none;transform:translateY(-8px) scale(0.97);transition:opacity 0.18s ease,transform 0.18s ease;overflow:hidden;}
848.settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
849.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);}
850.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;} .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
851.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;}
852.scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
853.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;}
854.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);}
855.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;}
856.page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
857.panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}
858h1{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
859.muted{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;} code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;background:var(--surface-2);padding:1px 5px;border-radius:5px;}
860.summary-strip{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:14px;margin-bottom:18px;}
861@media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
862.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);}
863.stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
864.stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
865.stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
866.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);}
867.chart-box{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;margin-bottom:18px;}
868.own-chart-head{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:14px;}
869.chart-box-title{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;}
870.own-control-groups{display:flex;align-items:center;gap:10px;flex-wrap:wrap;}
871.own-controls{display:inline-flex;gap:6px;background:var(--surface-2);border:1px solid var(--line);border-radius:999px;padding:3px;}
872.own-filter,.own-scope{border:none;background:none;color:var(--muted);font-size:12px;font-weight:700;padding:5px 12px;border-radius:999px;cursor:pointer;transition:background .15s ease,color .15s ease;}
873.own-filter:hover,.own-scope:hover{color:var(--text);} .own-filter.active,.own-scope.active{background:var(--oxide);color:#fff;}
874.own-bar-row{display:grid;grid-template-columns:180px 1fr 78px;align-items:center;gap:12px;padding:5px 0;}
875.own-bar-name{font-size:13px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
876.own-bar-track{height:14px;border-radius:7px;background:var(--surface-2);overflow:hidden;}
877.own-bar-fill{height:100%;border-radius:7px;transition:width .35s ease;min-width:2px;}
878.own-bar-val{font-size:12px;font-weight:700;text-align:right;font-variant-numeric:tabular-nums;color:var(--muted);}
879@media(max-width:700px){.own-bar-row{grid-template-columns:120px 1fr 60px;}}
880.data-table{width:100%;border-collapse:collapse;font-size:13px;}
881.data-table th{text-align:left;font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);padding:10px 12px;border-bottom:2px solid var(--line);white-space:nowrap;}
882.data-table td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--line);white-space:nowrap;vertical-align:middle;}
883.data-table tr:last-child td{border-bottom:none;}
884.data-table tbody tr:hover td{background:var(--surface-2);}
885.num{text-align:right!important;font-variant-numeric:tabular-nums;}
886.own-email{color:var(--muted);font-size:12px;}
887.own-dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:8px;vertical-align:middle;}
888.own-empty{display:flex;flex-direction:column;align-items:center;gap:12px;text-align:center;color:var(--muted-2);padding:40px 20px;}
889.own-empty svg{opacity:0.4;} .own-empty-title{font-size:16px;font-weight:800;color:var(--text);}
890.own-code{background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:10px 14px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;color:var(--oxide-2);}
891.site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;} .site-footer a{color:var(--oxide-2);text-decoration:none;} .site-footer a:hover{text-decoration:underline;}
892.merge-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(325px,1fr));gap:11px;margin-bottom:14px;}
893.merge-opt{display:flex;align-items:center;gap:11px;padding:13px 17px;border:1px solid var(--line);border-radius:12px;background:var(--surface-2);cursor:pointer;transition:transform .22s cubic-bezier(.16,1,.3,1),border-color .18s ease,background .18s ease,box-shadow .22s ease;}
894.merge-opt:hover{border-color:var(--oxide);background:var(--surface);transform:translateY(-3px) scale(1.02);box-shadow:0 12px 28px rgba(77,44,20,0.18);}
895.merge-opt:hover .merge-opt-dot{transform:scale(1.35);box-shadow:0 0 0 4px rgba(196,92,16,0.15);}
896.merge-opt:hover .merge-opt-name{color:var(--oxide-2);}
897.merge-opt:active{transform:translateY(-1px) scale(1.0);}
898.merge-opt input{accent-color:var(--oxide);width:17px;height:17px;flex:0 0 auto;cursor:pointer;}
899.merge-opt-dot{width:11px;height:11px;border-radius:50%;flex:0 0 auto;transition:transform .2s ease,box-shadow .2s ease;}
900.merge-opt-name{font-size:15px;font-weight:700;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
901.merge-opt-email{font-size:12px;color:var(--muted);margin-left:auto;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;}
902.merge-controls{display:flex;flex-wrap:wrap;align-items:center;gap:10px;}
903.merge-name-input{flex:1 1 240px;padding:9px 12px;border:1px solid var(--line);border-radius:10px;background:var(--surface-2);color:var(--text);font-size:13px;outline:none;}
904.merge-name-input:focus{border-color:var(--oxide);}
905.merge-btn{padding:9px 18px;border:none;border-radius:10px;background:var(--oxide);color:#fff;font-size:13px;font-weight:800;cursor:pointer;transition:background .15s ease,transform .15s ease;}
906.merge-btn:hover{background:var(--oxide-2);transform:translateY(-1px);}
907.merge-mailmap-link{font-size:12px;font-weight:700;color:var(--oxide-2);text-decoration:none;}
908.merge-mailmap-link:hover{text-decoration:underline;}
909.merge-existing{margin-top:16px;border-top:1px solid var(--line);padding-top:12px;}
910.merge-existing-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-bottom:8px;}
911.merge-chip{display:flex;align-items:center;gap:12px;padding:8px 12px;border:1px solid var(--line);border-radius:10px;background:var(--surface-2);margin-bottom:6px;}
912.merge-chip-text{font-size:12px;color:var(--muted);flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
913.merge-chip-text strong{color:var(--text);font-size:13px;}
914.merge-unmerge{padding:5px 12px;border:1px solid var(--line-strong);border-radius:8px;background:var(--surface);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;}
915.merge-unmerge:hover{background:var(--surface-2);border-color:var(--oxide);color:var(--oxide-2);}
916.merge-mailmap-note{margin:12px 0 0;font-size:12px;line-height:1.6;color:var(--muted);background:var(--surface-2);border:1px solid var(--line);border-left:3px solid var(--oxide);border-radius:8px;padding:10px 13px;}
917.merge-mailmap-note strong{color:var(--text);}
918.own-project-bar{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:0 0 18px;}
919.own-project-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}
920.own-project-select{padding:9px 34px 9px 13px;border:1px solid var(--line-strong);border-radius:10px;background:var(--surface);color:var(--text);font-size:13px;font-weight:700;cursor:pointer;outline:none;appearance:none;-webkit-appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%237b675b' stroke-width='2.5'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center;min-width:220px;transition:border-color .15s ease;}
921.own-project-select:hover{border-color:var(--oxide);} .own-project-select:focus{border-color:var(--oxide);}
922.own-project-hint{font-size:12px;color:var(--muted-2);}
923.own-bar-row{position:relative;border-radius:8px;transition:transform .22s cubic-bezier(.16,1,.3,1),background .22s ease;}
924.own-bar-row:hover{transform:translateX(6px);background:var(--surface-2);z-index:6;}
925.own-bar-row:hover .own-bar-fill{filter:brightness(1.08) saturate(1.12);box-shadow:0 3px 12px rgba(0,0,0,.22);}
926.own-bar-row:hover .own-bar-val{color:var(--oxide);}
927.own-bar-fill{transition:width .35s ease,filter .2s ease,box-shadow .2s ease;}
928.own-bar-name a.own-profile-link{color:inherit;text-decoration:none;}
929.own-bar-name a.own-profile-link:hover{color:var(--oxide);text-decoration:underline;}
930.own-profile-link{color:var(--oxide-2);text-decoration:none;font-weight:inherit;}
931.own-profile-link:hover{text-decoration:underline;}
932.stat-chip-tip{position:absolute;top:calc(100% + 10px);left:50%;transform:translateX(-50%) translateY(4px);background:var(--text);color:var(--bg);padding:9px 13px;border-radius:9px;font-size:11.5px;font-weight:600;line-height:1.5;width:max-content;max-width:280px;pointer-events:none;opacity:0;transition:opacity .2s ease,transform .2s ease;z-index:200;box-shadow:0 10px 28px rgba(0,0,0,.28);}
933.stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:6px solid transparent;border-bottom-color:var(--text);}
934.stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
935.own-charts-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:18px;}
936@media(max-width:900px){.own-charts-grid{grid-template-columns:1fr;}}
937.own-canvas-wrap{position:relative;width:100%;height:300px;margin-top:8px;}
938.own-canvas-empty{color:var(--muted-2);font-size:13px;padding:28px 4px;text-align:center;}
939.own-canvas-note{color:var(--muted-2);font-size:11.5px;margin-top:8px;text-align:center;}
940.own-legend{display:flex;flex-wrap:wrap;justify-content:center;gap:8px;margin-top:12px;}
941.own-legend-item{display:inline-flex;align-items:center;gap:7px;background:var(--surface-2);border:1px solid var(--line);border-radius:999px;padding:5px 13px;font-size:12px;font-weight:700;color:var(--text);cursor:pointer;transition:transform .18s cubic-bezier(.16,1,.3,1),box-shadow .18s ease,border-color .18s ease,background .18s ease;}
942.own-legend-item:hover,.own-legend-item.active{transform:translateY(-4px) scale(1.07);box-shadow:0 10px 22px rgba(77,44,20,0.22);border-color:var(--oxide);background:var(--surface);}
943.own-legend-item:active{transform:translateY(-1px) scale(1.02);}
944.own-legend-swatch{width:12px;height:12px;border-radius:3px;flex:0 0 auto;transition:transform .18s ease;}
945.own-legend-item:hover .own-legend-swatch,.own-legend-item.active .own-legend-swatch{transform:scale(1.3);}
946.own-legend-label{white-space:nowrap;}
947.own-legend-modal{margin-top:16px;}
948#own-bars{max-height:540px;overflow-y:auto;overflow-x:clip;padding-right:4px;}
949#own-bars::-webkit-scrollbar{width:9px;} #own-bars::-webkit-scrollbar-thumb{background:var(--line-strong);border-radius:6px;} #own-bars::-webkit-scrollbar-track{background:transparent;}
950.own-table-hint{font-size:12px;color:var(--muted);margin:-4px 0 10px;line-height:1.55;} .own-table-hint strong{color:var(--text);} .own-table-hint em{color:var(--oxide-2);font-style:normal;font-weight:700;}
951.own-email-copy{font-family:inherit;font-size:12px;color:var(--muted);background:none;border:none;padding:2px 6px;margin:-2px -6px;border-radius:6px;cursor:pointer;text-align:left;transition:background .15s ease,color .15s ease;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
952.own-email-copy:hover{background:var(--surface-2);color:var(--oxide-2);}
953.own-email-copy.copied{color:#2a6846;} body.dark-theme .own-email-copy.copied{color:#5aba8a;}
954.own-footnote{padding:16px 18px;} .own-footnote .muted{margin:0;}
955.own-copy-toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%) translateY(10px);background:var(--text);color:var(--bg);padding:10px 18px;border-radius:10px;font-size:13px;font-weight:700;box-shadow:0 12px 32px rgba(0,0,0,.3);z-index:9999;opacity:0;transition:opacity .2s ease,transform .2s ease;pointer-events:none;}
956.own-copy-toast.show{opacity:1;transform:translateX(-50%) translateY(0);}
957.own-float-tip{position:fixed;top:0;left:0;min-width:230px;max-width:340px;background:var(--surface);border:1px solid var(--line-strong);border-radius:12px;box-shadow:0 16px 40px rgba(0,0,0,.28);padding:12px 14px;pointer-events:none;opacity:0;transform:scale(.97);transition:opacity .12s ease,transform .12s ease;z-index:9999;}
958.own-float-tip.show{opacity:1;transform:scale(1);}
959.own-bar-tip-head{display:flex;align-items:center;gap:8px;font-size:14px;font-weight:800;color:var(--text);margin-bottom:2px;}
960.own-bar-tip-swatch{width:11px;height:11px;border-radius:50%;flex:0 0 auto;}
961.own-bar-tip-email{font-size:11.5px;color:var(--muted);margin-bottom:8px;word-break:break-all;}
962.own-bar-tip-cat{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--oxide);}
963.own-bar-tip-desc{font-size:12px;line-height:1.55;color:var(--muted);margin:5px 0 9px;}
964.own-bar-tip-num{font-size:19px;font-weight:900;color:var(--text);font-variant-numeric:tabular-nums;}
965.own-bar-tip-num span{font-size:11px;font-weight:700;color:var(--muted-2);text-transform:uppercase;letter-spacing:.05em;margin-left:3px;}
966.own-charts-sub{margin:0 0 12px;}
967.chart-box .toolbar{display:flex;flex-wrap:wrap;justify-content:space-between;gap:12px;align-items:center;margin-bottom:6px;}
968.chart-box .toolbar-left{display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
969.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;}
970.chart-expand-btn:hover{background:var(--surface-2);color:var(--text);}
971.own-canvas-wrap-donut{height:340px;}
972.chart-select{padding:6px 10px;border:1px solid var(--line-strong);border-radius:8px;background:var(--surface);color:var(--text);font-size:13px;font-weight:600;cursor:pointer;outline:none;}
973.chart-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.55);z-index:10000;display:flex;align-items:center;justify-content:center;padding:24px;box-sizing:border-box;}
974.chart-modal{background:var(--bg);border-radius:16px;padding:24px 28px;max-width:1500px;width:100%;max-height:92vh;overflow-y:auto;position:relative;box-shadow:0 24px 80px rgba(0,0,0,0.3);}
975body.dark-theme .chart-modal{background:var(--surface);}
976.chart-modal-header{display:flex;align-items:center;gap:12px;flex-wrap:nowrap;margin:0 0 16px;padding-right:44px;}
977.chart-modal-title{font-size:15px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:var(--text);margin:0;flex:1 1 auto;min-width:0;}
978.chart-modal-subtitle{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 16px;display:block;}
979.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;}
980.chart-modal-close:hover{opacity:.7;}
981.own-table-scroll{overflow-x:auto;}
982.own-contrib-table{table-layout:fixed;}
983.own-contrib-table th{position:relative;cursor:pointer;user-select:none;overflow:hidden;text-overflow:ellipsis;}
984.own-contrib-table th .own-sort-ind{margin-left:5px;font-size:9px;color:var(--muted-2);}
985.own-contrib-table th.own-sorted{color:var(--oxide);}
986.own-contrib-table td{overflow:hidden;text-overflow:ellipsis;}
987.own-col-resizer{position:absolute;top:0;right:0;width:7px;height:100%;cursor:col-resize;user-select:none;touch-action:none;}
988.own-col-resizer:hover{background:var(--oxide);opacity:.35;}
989body.own-resizing{cursor:col-resize;user-select:none;}
990.own-page-intro{margin:0 0 16px;}
991.lang-cell{display:inline-flex;align-items:center;gap:9px;}
992.lang-badge{display:inline-flex;flex:0 0 auto;line-height:0;filter:drop-shadow(0 1px 2px rgba(0,0,0,0.18));}
993.lang-badge svg{display:block;border-radius:5px;}
994.lang-cell-name{font-weight:600;}
995.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-strong);background:var(--surface-2);color:var(--text);text-decoration:none;transition:background .12s ease;white-space:nowrap;}
996.btn:hover{background:var(--line);}
997.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:16px;position:relative;z-index:1;}
998.watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
999.watched-bar-left>svg{color:var(--muted);flex-shrink:0;}
1000.watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
1001.watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
1002.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:340px;}
1003.watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
1004.watched-chip form{display:inline;margin:0;}
1005.watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
1006.watched-chip-rm:hover{color:var(--oxide);}
1007.watched-none{font-size:12px;color:var(--muted);font-style:italic;}
1008.watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
1009.watched-bar-right form{display:inline;margin:0;}
1010.watched-bar-right .btn{box-sizing:border-box;height:30px;}
1011body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}"#
1012}
1013
1014fn ownership_page_nav() -> &'static str {
1017 r#"<div class="top-nav"><div class="top-nav-inner">
1018 <a class="brand" href="/"><img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo"><div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Code Ownership</div></div></a>
1019 <div class="nav-right">
1020 <a class="nav-pill" href="/">Home</a>
1021 <div class="nav-dropdown">
1022 <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>
1023 <div class="nav-dropdown-menu"><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></div>
1024 </div>
1025 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
1026 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
1027 <div class="nav-dropdown">
1028 <a href="/git-browser" class="nav-dropdown-btn sx-8c38ef73" >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>
1029 <div class="nav-dropdown-menu">
1030 <a class="sx-ee1cc7d2" href="/code-ownership" ><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>Code Ownership</a>
1031 <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>
1032 </div>
1033 </div>
1034 <div class="server-status-wrap" id="server-status-wrap">
1035 <div class="nav-pill server-online-pill" id="server-status-pill"><span class="status-dot" id="status-dot"></span><span id="server-status-label">Server</span><span class="sx-d60f2ef3" id="server-ping-ms" ></span></div>
1036 <div class="server-status-tip">OxideSLOC is running — accessible on your network.<span class="sx-238af6bc" id="server-tip-ping" ></span></div>
1037 </div>
1038 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings"><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 1 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 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.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 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.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 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.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></button>
1039 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme"><svg class="icon-moon" viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg><svg class="icon-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line></svg></button>
1040 </div>
1041 </div></div>"#
1042}
1043
1044fn ownership_page_scripts() -> &'static str {
1047 r#"(function(){
1048 var b=document.body;
1049 try{if(localStorage.getItem('oxide-theme')==='dark')b.classList.add('dark-theme');}catch(e){}
1050 var tgl=document.getElementById('theme-toggle');
1051 if(tgl)tgl.addEventListener('click',function(){var d=b.classList.toggle('dark-theme');try{localStorage.setItem('oxide-theme',d?'dark':'light');}catch(e){}});
1052})();
1053(function(){
1054 var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
1055 if(!wms.length)return;var placed=[];
1056 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;}
1057 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];}
1058 var half=Math.floor(wms.length/2);
1059 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;});
1060})();
1061(function(){
1062 var container=document.getElementById('code-particles');if(!container)return;
1063 var snippets = ['NUM sloc','fn analyze()','code_lines','0 mixed','blanks: NUM','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','NUM physical','files: NUM','NUM comments','cargo build','Ok(run)','Vec<GEN>','match lang','fn main()','.rs .go .py','sloc_core','render_html','NUM code','HashMap<GEN, GN2>','Option<GEN>','&str','author AUT','last_change','ratio 0.CPX','complexity CPX','tokens: NUM','?; // try','let owner','commits: NUM','#[tokio::test]','assert_eq!','PCT% cover','blame AUT','churn PCT%','HEAD~CPX','dedup PCT%','cyclomatic CPX','impl Iterator','.await','+NUM -NM2','ownership','Span<GEN>','pub struct','trait Lang','unsafe','mixed: NUM','anyhow::Result','fn detect()','PathBuf','+NUM loc','coverage PCT%','physical: NUM','ULOC NUM','dup PCT%','let mut run']; var __GT=['Author','String','Token','FileRecord','Lang','u32','usize','PathBuf','Commit','Line','Span','Metric','Record','u64','char','Utf8','Ratio','Blame']; var __AN=['nima','core','web','cli','git','lang','ci','bot','sloc'];
1064 for(var i=0;i<38;i++){(function(idx){var el=document.createElement('span');el.className='code-particle';el.textContent=snippets[idx%snippets.length].split('NUM').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('NM2').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('GEN').join(__GT[Math.floor(Math.random()*__GT.length)]).split('GN2').join(__GT[Math.floor(Math.random()*__GT.length)]).split('AUT').join(__AN[Math.floor(Math.random()*__AN.length)]).split('CPX').join(''+Math.floor(Math.random()*40+1)).split('PCT').join(''+Math.floor(Math.random()*44+1));var left=Math.random()*94+2,top=Math.random()*88+6,dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1),rot=(Math.random()*26-13).toFixed(1),op=(Math.random() * 0.108 + 0.072).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);}
1065})();
1066(function(){
1067 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'}];
1068 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);});}
1069 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
1070 var btn=document.getElementById('settings-btn');if(!btn)return;
1071 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
1072 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>';
1073 document.body.appendChild(m);
1074 var g=document.getElementById('scheme-grid');
1075 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);el.addEventListener('click',function(){ap(s);});g.appendChild(el);});
1076 var cl=document.getElementById('settings-close');
1077 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');});
1078 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
1079 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
1080})();
1081(function(){
1082 var dot=document.getElementById('status-dot'),pingEl=document.getElementById('server-ping-ms'),tipEl=document.getElementById('server-tip-ping');
1083 if(location.protocol==='file:')return;
1084 function setDot(ms){if(!dot)return;if(ms<100){dot.style.background='#26d768';}else if(ms<300){dot.style.background='#f5a623';}else{dot.style.background='#e05c5c';}}
1085 function ping(){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='';});}
1086 ping();setInterval(ping,5000);
1087})();
1088(function(){
1089 var el=document.getElementById('own-data');if(!el)return;
1090 var data;try{data=JSON.parse(el.textContent);}catch(e){return;}
1091 var wrap=document.getElementById('own-bars');if(!wrap)return;
1092 var btns=document.querySelectorAll('.own-filter');
1093 var scopeBtns=document.querySelectorAll('.own-scope');
1094 var CAT={
1095 code:{label:'Code lines',desc:'Physical lines that contain executable source code. Comment-only and blank lines are excluded.'},
1096 comment:{label:'Comment lines',desc:'Physical lines that are entirely comments or documentation.'},
1097 blank:{label:'Blank lines',desc:'Empty or whitespace-only lines separating code.'},
1098 total:{label:'Total lines',desc:'Every physical line this contributor last touched \u2014 code, comments and blanks combined.'}
1099 };
1100 var SCOPE_LABEL={all:'',dev:' (development files)',test:' (test files)'};
1101 var activeMetric='code',activeScope='all';
1102 function val(d,metric,scope){
1103 var base=Number(d[metric]||0);
1104 if(scope==='test')return Number(d['test_'+metric]||0);
1105 if(scope==='dev')return Math.max(0,base-Number(d['test_'+metric]||0));
1106 return base;
1107 }
1108 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();}
1109 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
1110 // Single cursor-following tooltip, reused across renders and rows.
1111 var tip=document.createElement('div');tip.className='own-float-tip';document.body.appendChild(tip);
1112 var curRow=null;
1113 function fillTip(d){
1114 var cat=CAT[activeMetric]||CAT.code;
1115 tip.innerHTML='<div class="own-bar-tip-head"><span class="own-bar-tip-swatch"></span><span class="tn"></span></div>'
1116 +'<div class="own-bar-tip-email"></div>'
1117 +'<div class="own-bar-tip-cat"></div>'
1118 +'<div class="own-bar-tip-desc"></div>'
1119 +'<div class="own-bar-tip-num"><span class="nv"></span> <span>lines</span></div>';
1120 tip.querySelector('.own-bar-tip-swatch').style.background=d.color;
1121 tip.querySelector('.tn').textContent=d.name;
1122 tip.querySelector('.own-bar-tip-email').textContent=d.email||'';
1123 tip.querySelector('.own-bar-tip-cat').textContent=cat.label+(SCOPE_LABEL[activeScope]||'');
1124 tip.querySelector('.own-bar-tip-desc').textContent=cat.desc;
1125 tip.querySelector('.nv').textContent=val(d,activeMetric,activeScope).toLocaleString();
1126 }
1127 function moveTip(e){
1128 var x=e.clientX+16,y=e.clientY+16,w=tip.offsetWidth,h=tip.offsetHeight;
1129 if(x+w>window.innerWidth-8)x=e.clientX-w-16;
1130 if(y+h>window.innerHeight-8)y=e.clientY-h-16;
1131 if(x<8)x=8;if(y<8)y=8;
1132 tip.style.left=x+'px';tip.style.top=y+'px';
1133 }
1134 wrap.addEventListener('mousemove',function(e){
1135 var row=e.target.closest('.own-bar-row');
1136 if(!row||!row._d){tip.classList.remove('show');curRow=null;return;}
1137 if(row!==curRow){curRow=row;fillTip(row._d);tip.classList.add('show');}
1138 moveTip(e);
1139 });
1140 wrap.addEventListener('mouseleave',function(){tip.classList.remove('show');curRow=null;});
1141 function render(){
1142 var metric=activeMetric,scope=activeScope;
1143 var max=1;data.forEach(function(d){var v=val(d,metric,scope);if(v>max)max=v;});
1144 var rows=data.slice().sort(function(x,y){return val(y,metric,scope)-val(x,metric,scope);});
1145 var html='';
1146 rows.forEach(function(d){
1147 var v=val(d,metric,scope);
1148 var pct=Math.round(v/max*100);
1149 var nm=d.profile?('<a class="own-profile-link" href="'+esc(d.profile)+'" target="_blank" rel="noopener">'+esc(d.name)+'</a>'):esc(d.name);
1150 html+='<div class="own-bar-row">'
1151 +'<div class="own-bar-name" title="'+esc(d.email)+'">'+nm+'</div>'
1152 +'<div class="own-bar-track"><div class="own-bar-fill" data-sx-style="width:'+pct+'%;background:'+d.color+';"></div></div>'
1153 +'<div class="own-bar-val">'+fmt(v)+'</div>'
1154 +'</div>';
1155 });
1156 wrap.innerHTML=html;
1157 if(window.sxApply)window.sxApply(wrap);
1158 var els=wrap.querySelectorAll('.own-bar-row');
1159 for(var i=0;i<els.length;i++){els[i]._d=rows[i];}
1160 curRow=null;tip.classList.remove('show');
1161 }
1162 btns.forEach(function(bn){bn.addEventListener('click',function(){btns.forEach(function(x){x.classList.remove('active');});bn.classList.add('active');activeMetric=bn.getAttribute('data-metric');render();});});
1163 scopeBtns.forEach(function(bn){bn.addEventListener('click',function(){scopeBtns.forEach(function(x){x.classList.remove('active');});bn.classList.add('active');activeScope=bn.getAttribute('data-scope');render();document.dispatchEvent(new CustomEvent('own-scope-change',{detail:activeScope}));});});
1164 render();
1165})();
1166(function(){
1167 var s=document.getElementById('own-project-select');if(!s)return;
1168 s.addEventListener('change',function(){var v=s.value;window.location=v?('/code-ownership?project='+encodeURIComponent(v)):'/code-ownership';});
1169})();
1170(function(){
1171 // Click any contributor email in the table to copy it to the clipboard.
1172 var toast=document.getElementById('own-copy-toast');var toastT=null;
1173 function showToast(msg){if(!toast)return;toast.textContent=msg;toast.hidden=false;toast.classList.add('show');if(toastT)clearTimeout(toastT);toastT=setTimeout(function(){toast.classList.remove('show');},1600);}
1174 function copy(text,btn){
1175 function ok(){if(btn){btn.classList.add('copied');setTimeout(function(){btn.classList.remove('copied');},1200);}showToast('Copied '+text);}
1176 if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(text).then(ok).catch(function(){fallback(text,ok);});}
1177 else{fallback(text,ok);}
1178 }
1179 function fallback(text,ok){try{var ta=document.createElement('textarea');ta.value=text;ta.style.position='fixed';ta.style.opacity='0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok();}catch(e){showToast('Copy failed');}}
1180 document.addEventListener('click',function(e){var b=e.target.closest('.own-email-copy');if(!b)return;var em=b.getAttribute('data-email');if(em)copy(em,b);});
1181})();
1182(function(){
1183 if(typeof Chart==='undefined')return;
1184 var el=document.getElementById('own-data');if(!el)return;
1185 var data;try{data=JSON.parse(el.textContent);}catch(e){return;}
1186 var comp=document.getElementById('own-canvas-composition'),share=document.getElementById('own-canvas-share');
1187 var FONT='Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif';
1188 // Canonical report palette + helpers (mirrors sloc-report result charts).
1189 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
1190 var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
1191 var TOPN=12,chartScope='all';
1192 function isDark(){return document.body.classList.contains('dark-theme');}
1193 function clr(){return isDark()?{text:'#d4c5b8',grid:'rgba(255,255,255,0.10)',surface:'#261c17'}:{text:'#43342d',grid:'#e6d0bf',surface:'#ffffff'};}
1194 function hexAlpha(hex,a){var r=parseInt(hex.slice(1,3),16),g=parseInt(hex.slice(3,5),16),b=parseInt(hex.slice(5,7),16);return 'rgba('+r+','+g+','+b+','+a+')';}
1195 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 Math.round(v/1e3)+'K';return v.toLocaleString();}
1196 function chartCursor(e,els){var t=e.native&&e.native.target;if(t)t.style.cursor=els.length?'pointer':'default';}
1197 function legendCursorOn(e){var t=e.native&&e.native.target;if(t)t.style.cursor='pointer';}
1198 function legendCursorOff(e){var t=e.native&&e.native.target;if(t)t.style.cursor='default';}
1199 // Cursor-following tooltip positioner so the composition tooltip tracks the mouse (and can be
1200 // nudged off the bar) instead of pinning to the bar centre and blocking it.
1201 if(Chart.Tooltip&&Chart.Tooltip.positioners&&!Chart.Tooltip.positioners.ownCursor){
1202 Chart.Tooltip.positioners.ownCursor=function(items,evtPos){return evtPos?{x:evtPos.x,y:evtPos.y}:false;};
1203 }
1204 var BLANK=function(){return isDark()?'#524238':'#e6d0bf';};
1205 var BLANK_H=function(){return isDark()?'#6b5548':'#d8bfad';};
1206 function sval(d,metric,scope){var base=Number(d[metric]||0);if(scope==='test')return Number(d['test_'+metric]||0);if(scope==='dev')return Math.max(0,base-Number(d['test_'+metric]||0));return base;}
1207 function buildView(scope){
1208 var arr=data.map(function(d,i){return {name:d.name,color:d.color||PALETTE[i%PALETTE.length],profile:d.profile,
1209 code:sval(d,'code',scope),comment:sval(d,'comment',scope),blank:sval(d,'blank',scope)};});
1210 arr.sort(function(a,b){return b.code-a.code;});
1211 if(arr.length>TOPN){
1212 var head=arr.slice(0,TOPN),rest=arr.slice(TOPN);
1213 var o={name:'Others ('+rest.length+')',color:(isDark()?'#6b5548':'#c9b3a1'),profile:null,code:0,comment:0,blank:0};
1214 rest.forEach(function(r){o.code+=r.code;o.comment+=r.comment;o.blank+=r.blank;});
1215 head.push(o);
1216 return {view:head,truncated:rest.length};
1217 }
1218 return {view:arr,truncated:0};
1219 }
1220 function setNote(id,trunc){var n=document.getElementById(id);if(!n)return;if(trunc>0){n.textContent='Showing top '+TOPN+' contributors; '+trunc+' more grouped as "Others".';n.hidden=false;}else{n.hidden=true;}}
1221 function openProfileFrom(view,i){var p=view[i]&&view[i].profile;if(p)window.open(p,'_blank','noopener');}
1222 // Plugin: value label inside each visible stacked-bar segment (report segLabelPlugin).
1223 var segLabelPlugin={afterDatasetsDraw:function(chart){var ctx=chart.ctx,nDs=chart.data.datasets.length;for(var di=0;di<nDs;di++){var meta=chart.getDatasetMeta(di);if(meta.hidden)continue;meta.data.forEach(function(elm,idx){var v=chart.data.datasets[di].data[idx]||0;if(!v)return;var w=Math.abs(elm.x-elm.base);if(w<28)return;ctx.save();ctx.font='600 10px '+FONT;ctx.fillStyle=(di===2)?(isDark()?'#f0e6dc':'#555'):'#fff';ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillText(fmt(v),elm.base+w/2,elm.y);ctx.restore();});}}};
1224 // Plugin: row total at the end of the stacked bar (report makeStackedEndPlugin).
1225 function makeStackedEndPlugin(){return {afterDatasetsDraw:function(chart){var ctx=chart.ctx,tc=clr().text,nDs=chart.data.datasets.length;if(!nDs)return;var lastMeta=chart.getDatasetMeta(nDs-1);lastMeta.data.forEach(function(elm,idx){var total=0;chart.data.datasets.forEach(function(ds){total+=ds.data[idx]||0;});if(!total)return;ctx.save();ctx.font='600 11px '+FONT;ctx.fillStyle=tc;ctx.textAlign='left';ctx.textBaseline='middle';ctx.fillText(fmt(total),elm.x+5,elm.y);ctx.restore();});}};}
1226 // Pick readable text color for a label drawn on a colored slice.
1227 function textOn(bg){if(bg&&bg.charAt(0)==='#'&&bg.length>=7){var r=parseInt(bg.slice(1,3),16),g=parseInt(bg.slice(3,5),16),b=parseInt(bg.slice(5,7),16);var lum=0.299*r+0.587*g+0.114*b;return lum>150?'#3a2a20':'#fff';}return '#fff';}
1228 // Plugin: permanent on-slice name + % labels, plus a centered total in the hole.
1229 var donutLabelPlugin={afterDatasetsDraw:function(chart){
1230 if(chart.config.type!=='doughnut')return;
1231 var ctx=chart.ctx,meta=chart.getDatasetMeta(0),ds=chart.data.datasets[0];
1232 var tot=ds.data.reduce(function(a,b){return a+Number(b);},0)||1;
1233 meta.data.forEach(function(arc,i){
1234 var v=Number(ds.data[i]||0);if(!v)return;
1235 var pct=v/tot*100,sweep=arc.endAngle-arc.startAngle;
1236 var ang=(arc.startAngle+arc.endAngle)/2,r=(arc.innerRadius+arc.outerRadius)/2;
1237 var x=arc.x+Math.cos(ang)*r,y=arc.y+Math.sin(ang)*r;
1238 ctx.save();ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillStyle=textOn(ds.backgroundColor[i]);
1239 if(sweep>0.34){ctx.font='700 12px '+FONT;ctx.fillText(chart.data.labels[i],x,y-8);ctx.font='800 13px '+FONT;ctx.fillText((pct>=10?pct.toFixed(0):pct.toFixed(1))+'%',x,y+9);}
1240 else if(sweep>0.13){ctx.font='800 12px '+FONT;ctx.fillText(pct.toFixed(0)+'%',x,y);}
1241 ctx.restore();
1242 });
1243 var m0=meta.data[0];if(m0){var cc=clr();ctx.save();ctx.textAlign='center';ctx.textBaseline='middle';
1244 ctx.fillStyle=cc.text;ctx.font='800 20px '+FONT;ctx.fillText(fmt(tot),m0.x,m0.y-7);
1245 ctx.fillStyle=isDark()?'#b09080':'#7b675b';ctx.font='600 11px '+FONT;ctx.fillText('code lines',m0.x,m0.y+13);
1246 ctx.restore();}
1247 }};
1248 var charts=[];
1249 function destroy(){charts.forEach(function(c){try{c.destroy();}catch(e){}});charts=[];}
1250 if(!data.length){
1251 ['own-comp-wrap','own-share-wrap'].forEach(function(id){var w=document.getElementById(id);if(w)w.hidden=true;});
1252 ['own-comp-empty','own-share-empty'].forEach(function(id){var e2=document.getElementById(id);if(e2)e2.hidden=false;});
1253 var cb=document.getElementById('own-comp-expand'),sb=document.getElementById('own-share-expand');if(cb)cb.style.display='none';if(sb)sb.style.display='none';
1254 return;
1255 }
1256 // Restore a chart's slices/datasets to their base colours (clears any legend-hover dim/pop).
1257 function restoreChart(ch){
1258 if(!ch||!ch.data)return;
1259 ch.data.datasets.forEach(function(dst){
1260 if(dst._baseBg){dst.backgroundColor=dst._baseBg.slice();}
1261 else if(dst._base){dst.backgroundColor=dst._base;dst.hoverBackgroundColor=dst._baseHover;}
1262 });
1263 ch.setActiveElements([]);if(ch.tooltip)ch.tooltip.setActiveElements([],{});ch.update('none');
1264 }
1265 // Emphasise one legend entry: dim the others, pop the matching element, show its tooltip.
1266 function highlight(ch,kind,idx){
1267 if(kind==='datasets'){
1268 ch.data.datasets.forEach(function(dst,i){var base=dst._base;var faded=(base&&base.charAt(0)==='#'&&base.length===7)?hexAlpha(base,0.15):hexAlpha('#888888',0.15);dst.backgroundColor=i===idx?base:faded;dst.hoverBackgroundColor=dst.backgroundColor;});
1269 var n=ch.data.datasets.length,ae=[];for(var ii=0;ii<n;ii++){ae.push({datasetIndex:ii,index:0});}
1270 var fp=ch.getDatasetMeta(idx).data[0];ch.setActiveElements([{datasetIndex:idx,index:0}]);if(ch.tooltip)ch.tooltip.setActiveElements(ae,fp?{x:fp.x,y:fp.y}:{x:0,y:0});ch.update();
1271 }else{
1272 var bg=ch.data.datasets[0]._baseBg||ch.data.datasets[0].backgroundColor;
1273 ch.data.datasets[0].backgroundColor=bg.map(function(col,j){return j===idx?col:hexAlpha(col.charAt(0)==='#'&&col.length===7?col:'#888888',0.2);});
1274 var fp2=ch.getDatasetMeta(0).data[idx];ch.setActiveElements([{datasetIndex:0,index:idx}]);var pos=fp2&&fp2.tooltipPosition?fp2.tooltipPosition():(fp2?{x:fp2.x,y:fp2.y}:{x:0,y:0});if(ch.tooltip)ch.tooltip.setActiveElements([{datasetIndex:0,index:idx}],pos);ch.update();
1275 }
1276 }
1277 // Build the interactive HTML legend for a chart. Real mouseenter/leave events give a reliable
1278 // reset (canvas legends leave the dim state stuck) and let the legend items CSS-animate on hover.
1279 function buildLegend(ch,el,kind){
1280 if(!el)return;el.innerHTML='';
1281 var items;
1282 if(kind==='datasets'){
1283 items=ch.data.datasets.map(function(d,i){return {label:d.label,color:d._base||d.backgroundColor,idx:i,profile:null};});
1284 }else{
1285 var ds0=ch.data.datasets[0],bg=ds0._baseBg||ds0.backgroundColor,tot=ds0.data.reduce(function(a,b){return a+Number(b);},0)||1,view=ch.$view||[];
1286 items=ch.data.labels.map(function(lb,i){var pct=Number(ds0.data[i]||0)/tot*100;return {label:lb+' '+(pct>=10?pct.toFixed(0):pct.toFixed(1))+'%',color:bg[i],idx:i,profile:view[i]&&view[i].profile};});
1287 }
1288 items.forEach(function(it){
1289 var b=document.createElement('button');b.type='button';b.className='own-legend-item'+(it.profile?' has-profile':'');
1290 var sw=document.createElement('span');sw.className='own-legend-swatch';sw.style.setProperty('background',it.color);
1291 var tx=document.createElement('span');tx.className='own-legend-label';tx.textContent=it.label;
1292 b.appendChild(sw);b.appendChild(tx);
1293 // Highlight on enter/focus. The reset lives on the CONTAINER's mouseleave (below), not per
1294 // item: the item pops up on hover (transform), which can slip out from under the cursor and
1295 // miss a per-item mouseleave — leaving the chart stuck dimmed. Container mouseleave is reliable.
1296 b.addEventListener('mouseenter',function(){highlight(ch,kind,it.idx);});
1297 b.addEventListener('focus',function(){b.classList.add('active');highlight(ch,kind,it.idx);});
1298 b.addEventListener('blur',function(){b.classList.remove('active');restoreChart(ch);});
1299 if(it.profile)b.addEventListener('click',function(){window.open(it.profile,'_blank','noopener');});
1300 el.appendChild(b);
1301 });
1302 el.addEventListener('mouseleave',function(){restoreChart(ch);});
1303 }
1304 // ── Composition (stacked horizontal bar) config, shared by inline + Full View ──
1305 function compConfig(view,big){
1306 var c=clr();
1307 var cols=[OX,GN,BLANK()],hov=['#d97020','#3a8a5e',BLANK_H()];
1308 function ds(label,key,i){return {label:label,data:view.map(function(v){return v[key];}),backgroundColor:cols[i],hoverBackgroundColor:hov[i],_base:cols[i],_baseHover:hov[i],borderRadius:0,borderSkipped:false,maxBarThickness:big?380:150};}
1309 return {type:'bar',
1310 data:{labels:view.map(function(v){return v.name;}),datasets:[ds('Code','code',0),ds('Comments','comment',1),ds('Blank','blank',2)]},
1311 options:{indexAxis:'y',responsive:true,maintainAspectRatio:false,
1312 onHover:chartCursor,
1313 animation:{duration:500,easing:'easeOutQuart'},transitions:{active:{animation:{duration:180,easing:'easeOutQuart'}}},
1314 layout:{padding:{right:56}},
1315 onClick:function(e,els){if(els&&els.length)openProfileFrom(view,els[0].index);},
1316 scales:{x:{stacked:true,grid:{color:c.grid},ticks:{color:c.text,callback:function(v){return fmt(v);}}},
1317 y:{stacked:true,grid:{display:false},ticks:{color:c.text}}},
1318 plugins:{
1319 legend:{display:false},
1320 tooltip:{mode:'index',position:'ownCursor',caretPadding:16,yAlign:'bottom',callbacks:{
1321 title:function(items){return items.length?items[0].label:'';},
1322 label:function(ctx){return ' '+ctx.dataset.label+': '+Number(ctx.parsed.x||0).toLocaleString();},
1323 footer:function(items){var t=items.reduce(function(s,i){return s+(i.parsed.x||0);},0);return 'Total: '+Number(t).toLocaleString();}}}
1324 }},
1325 plugins:[makeStackedEndPlugin(),segLabelPlugin,{id:'compLeave',afterEvent:function(ch,a){if(a.event&&a.event.type==='mouseout')restoreChart(ch);}}]};
1326 }
1327 // ── Share (doughnut) config, shared by inline + Full View ──
1328 function shareConfig(view,big){
1329 var c=clr();
1330 var baseBg=view.map(function(v){return v.color;});
1331 return {type:'doughnut',
1332 data:{labels:view.map(function(v){return v.name;}),datasets:[{data:view.map(function(v){return v.code;}),
1333 backgroundColor:baseBg.slice(),_baseBg:baseBg.slice(),borderColor:c.surface,borderWidth:2,hoverOffset:big?26:18,hoverBorderColor:c.surface}]},
1334 options:{responsive:true,maintainAspectRatio:false,cutout:'62%',radius:big?'96%':'92%',
1335 animation:{animateRotate:true,duration:600,easing:'easeOutQuart'},transitions:{active:{animation:{duration:220,easing:'easeOutQuart'}}},
1336 onHover:chartCursor,
1337 onClick:function(e,els){if(els&&els.length)openProfileFrom(view,els[0].index);},
1338 layout:{padding:{left:6,right:6,top:6,bottom:6}},
1339 plugins:{
1340 legend:{display:false},
1341 tooltip:{callbacks:{label:function(ctx){var t=ctx.dataset.data.reduce(function(a,b){return a+Number(b);},0);var p=t?(Number(ctx.raw)/t*100):0;return ' '+ctx.label+': '+Number(ctx.raw).toLocaleString()+' ('+p.toFixed(1)+'%)';}}}}},
1342 plugins:[donutLabelPlugin,{id:'shareLeave',afterEvent:function(ch,a){if(a.event&&a.event.type==='mouseout')restoreChart(ch);}}]};
1343 }
1344 function build(){
1345 destroy();
1346 var vt=buildView(chartScope),view=vt.view;
1347 setNote('own-comp-note',vt.truncated);setNote('own-share-note',vt.truncated);
1348 var cwrap=document.getElementById('own-comp-wrap');
1349 if(cwrap)cwrap.style.height=Math.min(640,Math.max(240,view.length*34+70))+'px';
1350 if(comp){var cc=new Chart(comp.getContext('2d'),compConfig(view));cc.$view=view;charts.push(cc);buildLegend(cc,document.getElementById('own-comp-legend'),'datasets');}
1351 if(share){var sc=new Chart(share.getContext('2d'),shareConfig(view));sc.$view=view;charts.push(sc);buildLegend(sc,document.getElementById('own-share-legend'),'slices');}
1352 }
1353 build();
1354 document.addEventListener('own-scope-change',function(e){chartScope=(e&&e.detail)||'all';build();});
1355 var tgl=document.getElementById('theme-toggle');
1356 if(tgl)tgl.addEventListener('click',function(){setTimeout(build,70);});
1357 // ── Full View modals (report makeOverlay) ──
1358 function makeOverlay(title,h){
1359 var overlay=document.createElement('div');overlay.className='chart-modal-overlay';
1360 var maxH=Math.max(520,Math.floor(window.innerHeight*0.9)-110);
1361 var hAttr='height:'+Math.min(h||620,maxH)+'px;';
1362 overlay.innerHTML='<div class="chart-modal"><button class="chart-modal-close" aria-label="Close">×</button><div class="chart-modal-header"><span class="chart-modal-title">'+title+'</span></div><div style="position:relative;width:100%;'+hAttr+'"><canvas id="own-modal-canvas"></canvas></div><div class="own-legend own-legend-modal" id="own-modal-legend"></div></div>';
1363 document.body.appendChild(overlay);
1364 function close(){if(overlay.parentNode)document.body.removeChild(overlay);document.removeEventListener('keydown',onKey);}
1365 function onKey(ev){if(ev.key==='Escape')close();}
1366 overlay.querySelector('.chart-modal-close').addEventListener('click',close);
1367 overlay.addEventListener('click',function(e){if(e.target===overlay)close();});
1368 document.addEventListener('keydown',onKey);
1369 return document.getElementById('own-modal-canvas');
1370 }
1371 function openModalChart(title,h,cfgFn,kind){var view=buildView(chartScope).view;var cv2=makeOverlay(title,h);if(cv2)requestAnimationFrame(function(){var mc=new Chart(cv2,cfgFn(view,true));mc.$view=view;buildLegend(mc,document.getElementById('own-modal-legend'),kind);});}
1372 var compBtn=document.getElementById('own-comp-expand');
1373 if(compBtn)compBtn.addEventListener('click',function(){var n=buildView(chartScope).view.length;openModalChart('Line composition per contributor \u2014 Full View',Math.min(1000,Math.max(640,n*92+260)),compConfig,'datasets');});
1374 var shareBtn=document.getElementById('own-share-expand');
1375 if(shareBtn)shareBtn.addEventListener('click',function(){openModalChart('Share of codebase (code lines) \u2014 Full View',760,shareConfig,'slices');});
1376})();
1377(function(){
1378 // Contributors table: click a header to sort asc/desc; drag the right edge to resize a column.
1379 var table=document.getElementById('own-contrib-table');if(!table)return;
1380 var thead=table.tHead;if(!thead)return;
1381 var ths=Array.prototype.slice.call(thead.rows[0].cells);
1382 var tbody=table.tBodies[0];if(!tbody)return;
1383 var sortCol=-1,sortDir=1;
1384 function cellVal(row,i,kind){var td=row.cells[i];if(!td)return kind==='num'?0:'';var t=td.textContent.trim();if(kind==='num'){var n=parseFloat(t.replace(/[^0-9.\-]/g,''));return isNaN(n)?-Infinity:n;}return t.toLowerCase();}
1385 function sortBy(i){
1386 var kind=ths[i].getAttribute('data-sort')||'text';
1387 if(sortCol===i){sortDir=-sortDir;}else{sortCol=i;sortDir=(kind==='num')?-1:1;}
1388 var rows=Array.prototype.slice.call(tbody.rows);
1389 rows.sort(function(a,b){var av=cellVal(a,i,kind),bv=cellVal(b,i,kind);if(av<bv)return -1*sortDir;if(av>bv)return 1*sortDir;return 0;});
1390 rows.forEach(function(r){tbody.appendChild(r);});
1391 ths.forEach(function(th,j){th.classList.toggle('own-sorted',j===i);var ind=th.querySelector('.own-sort-ind');if(ind)ind.textContent=j===i?(sortDir>0?'\u25B2':'\u25BC'):'';});
1392 }
1393 ths.forEach(function(th,i){
1394 var ind=document.createElement('span');ind.className='own-sort-ind';th.appendChild(ind);
1395 th.addEventListener('click',function(e){if(e.target.classList.contains('own-col-resizer'))return;sortBy(i);});
1396 var res=document.createElement('div');res.className='own-col-resizer';th.appendChild(res);
1397 var startX=0,startW=0;
1398 res.addEventListener('mousedown',function(e){e.preventDefault();e.stopPropagation();startX=e.pageX;startW=th.offsetWidth;document.body.classList.add('own-resizing');
1399 function mv(ev){var w=Math.max(48,startW+(ev.pageX-startX));th.style.width=w+'px';}
1400 function up(){document.removeEventListener('mousemove',mv);document.removeEventListener('mouseup',up);document.body.classList.remove('own-resizing');}
1401 document.addEventListener('mousemove',mv);document.addEventListener('mouseup',up);});
1402 });
1403})();
1404(function(){
1405 // Watched Folders: Choose opens the native directory picker, then adds it and reloads.
1406 var btn=document.getElementById('add-watched-btn');if(!btn)return;
1407 btn.addEventListener('click',function(){
1408 fetch('/pick-directory?kind=reports')
1409 .then(function(r){return r.ok?r.json():{cancelled:true};})
1410 .then(function(data){
1411 if(!data.cancelled&&data.selected_path){
1412 var form=document.createElement('form');form.method='POST';form.action='/watched-dirs/add';
1413 var ri=document.createElement('input');ri.type='hidden';ri.name='redirect_to';ri.value='/code-ownership';
1414 var fi=document.createElement('input');fi.type='hidden';fi.name='folder_path';fi.value=data.selected_path;
1415 form.appendChild(ri);form.appendChild(fi);document.body.appendChild(form);form.submit();
1416 }
1417 })
1418 .catch(function(e){alert('Could not open folder picker: '+e);});
1419 });
1420})();"#
1421}
1422
1423fn identities_path(state: &AppState) -> PathBuf {
1426 state.registry_path.parent().map_or_else(
1427 || PathBuf::from("identities.json"),
1428 |p| p.join("identities.json"),
1429 )
1430}
1431
1432#[derive(Deserialize, Default)]
1433struct OwnershipQuery {
1434 project: Option<String>,
1436}
1437
1438async fn code_ownership_handler(
1439 State(state): State<AppState>,
1440 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
1441 Query(query): Query<OwnershipQuery>,
1442) -> Response {
1443 let selected = query.project.as_deref().filter(|s| !s.is_empty());
1444
1445 auto_scan_watched_dirs(&state).await;
1448 let watched_dirs_list: Vec<String> = {
1449 let wd = state.watched_dirs.lock().await;
1450 wd.dirs.iter().map(|p| p.display().to_string()).collect()
1451 };
1452
1453 let (projects, json_path): (Vec<String>, Option<PathBuf>) = {
1457 let reg = state.registry.lock().await;
1458 let mut seen = std::collections::HashSet::new();
1459 let mut projects = Vec::new();
1460 for e in ®.entries {
1461 if seen.insert(e.project_label.clone()) {
1462 projects.push(e.project_label.clone());
1463 }
1464 }
1465 let json_path = match selected {
1466 Some(label) => reg
1467 .entries
1468 .iter()
1469 .find(|e| e.project_label == label)
1470 .and_then(|e| e.json_path.clone()),
1471 None => reg.entries.first().and_then(|e| e.json_path.clone()),
1472 };
1473 (projects, json_path)
1474 };
1475
1476 let mut latest_run: Option<AnalysisRun> = if let Some(p) = json_path {
1477 tokio::fs::read_to_string(&p)
1478 .await
1479 .ok()
1480 .as_deref()
1481 .and_then(|s| serde_json::from_str(s).ok())
1482 } else {
1483 None
1484 };
1485
1486 let map = IdentityMap::load(&identities_path(&state));
1489 if let Some(run) = latest_run.as_mut() {
1490 auto_merge_noreply_identities(run);
1491 apply_identity_map(run, &map);
1492 }
1493
1494 let project_label = latest_run
1495 .as_ref()
1496 .and_then(|r| r.input_roots.first())
1497 .map(|p| {
1498 p.rsplit(['/', '\\'])
1499 .find(|s| !s.is_empty())
1500 .unwrap_or(p.as_str())
1501 .to_string()
1502 })
1503 .unwrap_or_else(|| "workspace".to_string());
1504
1505 let watched_bar = render_watched_bar(state.server_mode, &watched_dirs_list, "/code-ownership");
1506
1507 let html = render_code_ownership_html(
1508 &csp_nonce,
1509 latest_run.as_ref(),
1510 &project_label,
1511 &map.groups,
1512 &projects,
1513 selected,
1514 &watched_bar,
1515 );
1516 Html(html).into_response()
1517}
1518
1519fn render_watched_bar(server_mode: bool, watched: &[String], redirect_to: &str) -> String {
1522 use std::fmt::Write as _;
1523 if server_mode {
1524 return 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();
1525 }
1526 let chips: String = if watched.is_empty() {
1527 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
1528 .to_string()
1529 } else {
1530 watched.iter().fold(String::new(), |mut s, d| {
1531 let esc = d.replace('&', "&").replace('"', """).replace('<', "<");
1532 let _ = write!(
1533 s,
1534 r#"<span class="watched-chip"><span class="watched-chip-path" title="{esc}">{esc}</span><form method="POST" action="/watched-dirs/remove"><input type="hidden" name="folder_path" value="{esc}"><input type="hidden" name="redirect_to" value="{redirect}"><button type="submit" class="watched-chip-rm" title="Remove folder">✕</button></form></span>"#,
1535 esc = esc,
1536 redirect = redirect_to,
1537 );
1538 s
1539 })
1540 };
1541 format!(
1542 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">{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"><input type="hidden" name="redirect_to" value="{redirect}"><button type="submit" class="btn">↻ Refresh</button></form></div></div>"#,
1543 chips = chips,
1544 redirect = redirect_to,
1545 )
1546}
1547
1548fn parse_form_pairs(body: &str) -> Vec<(String, String)> {
1552 body.split('&')
1553 .filter(|s| !s.is_empty())
1554 .map(|pair| {
1555 let mut it = pair.splitn(2, '=');
1556 let k = urldecode(it.next().unwrap_or(""));
1557 let v = urldecode(it.next().unwrap_or(""));
1558 (k, v)
1559 })
1560 .collect()
1561}
1562
1563fn urldecode(s: &str) -> String {
1565 let b = s.as_bytes();
1566 let mut out = Vec::with_capacity(b.len());
1567 let mut i = 0;
1568 while i < b.len() {
1569 match b[i] {
1570 b'+' => {
1571 out.push(b' ');
1572 i += 1;
1573 }
1574 b'%' if i + 3 <= b.len() => {
1575 match u8::from_str_radix(std::str::from_utf8(&b[i + 1..i + 3]).unwrap_or(""), 16) {
1576 Ok(byte) => {
1577 out.push(byte);
1578 i += 3;
1579 }
1580 Err(_) => {
1581 out.push(b'%');
1582 i += 1;
1583 }
1584 }
1585 }
1586 c => {
1587 out.push(c);
1588 i += 1;
1589 }
1590 }
1591 }
1592 String::from_utf8_lossy(&out).into_owned()
1593}
1594
1595async fn ownership_merge_handler(State(state): State<AppState>, body: String) -> Response {
1598 let pairs = parse_form_pairs(&body);
1599 let emails: Vec<String> = pairs
1600 .iter()
1601 .filter(|(k, _)| k == "email")
1602 .map(|(_, v)| v.clone())
1603 .collect();
1604 let name = pairs
1605 .iter()
1606 .find(|(k, _)| k == "canonical_name")
1607 .map(|(_, v)| v.as_str());
1608 let path = identities_path(&state);
1609 let mut map = IdentityMap::load(&path);
1610 map.merge(&emails, name);
1611 let _ = map.save(&path);
1612 axum::response::Redirect::to(&merge_redirect_target(&pairs)).into_response()
1613}
1614
1615fn merge_redirect_target(pairs: &[(String, String)]) -> String {
1619 pairs
1620 .iter()
1621 .find(|(k, _)| k == "redirect_to")
1622 .map(|(_, v)| v.as_str())
1623 .filter(|v| v.starts_with('/') && !v.starts_with("//"))
1624 .unwrap_or("/code-ownership")
1625 .to_string()
1626}
1627
1628async fn ownership_unmerge_handler(State(state): State<AppState>, body: String) -> Response {
1630 let pairs = parse_form_pairs(&body);
1631 if let Some((_, email)) = pairs.iter().find(|(k, _)| k == "canonical_email") {
1632 let path = identities_path(&state);
1633 let mut map = IdentityMap::load(&path);
1634 map.unmerge(email);
1635 let _ = map.save(&path);
1636 }
1637 axum::response::Redirect::to(&merge_redirect_target(&pairs)).into_response()
1638}
1639
1640async fn ownership_mailmap_handler(State(state): State<AppState>) -> Response {
1642 let map = IdentityMap::load(&identities_path(&state));
1643 (
1644 [
1645 (header::CONTENT_TYPE, "text/plain; charset=utf-8"),
1646 (
1647 header::CONTENT_DISPOSITION,
1648 "attachment; filename=\".mailmap\"",
1649 ),
1650 ],
1651 map.to_mailmap(),
1652 )
1653 .into_response()
1654}
1655
1656#[allow(clippy::too_many_lines)]
1661fn render_code_ownership_html(
1662 nonce: &str,
1663 run: Option<&AnalysisRun>,
1664 project_label: &str,
1665 merge_groups: &[AuthorMergeGroup],
1666 projects: &[String],
1667 selected: Option<&str>,
1668 watched_bar: &str,
1669) -> String {
1670 let version = env!("CARGO_PKG_VERSION");
1671
1672 let authors = run.map(|r| r.authors.as_slice()).unwrap_or(&[]);
1674 let has_data = !authors.is_empty();
1675 let total_code: u64 = authors.iter().map(|a| a.counts.code_lines).sum();
1676
1677 let content = if !has_data {
1679 render_ownership_empty(project_label)
1680 } else {
1681 let rows = build_ownership_rows(run, total_code);
1682 let bus_factor = compute_bus_factor(authors, total_code);
1683 let lang_rows = build_language_rows(run, &rows);
1684 let data_json = ownership_data_json(&rows);
1685 render_ownership_populated(
1686 &rows,
1687 bus_factor,
1688 total_code,
1689 &lang_rows,
1690 &data_json,
1691 merge_groups,
1692 nonce,
1693 project_label,
1694 )
1695 };
1696
1697 let css = ownership_page_css();
1698 let nav = ownership_page_nav();
1699 let scripts = ownership_page_scripts();
1700 let project_selector = build_project_selector(projects, selected);
1701
1702 format!(
1703 r#"<!doctype html>
1704<html lang="en">
1705<head>
1706 <meta charset="utf-8" />
1707 <meta name="viewport" content="width=device-width, initial-scale=1" />
1708 <title>OxideSLOC | Code Ownership</title>
1709 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
1710 <link rel="stylesheet" href="/static/app.css">
1711 <script src="/static/app.js"></script>
1712 <script src="/static/chart.js"></script>
1713 <style nonce="{nonce}">{css}</style>
1714</head>
1715<body>
1716<div class="background-watermarks" aria-hidden="true">
1717 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
1718 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
1719 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
1720 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
1721 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
1722 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
1723</div>
1724<div class="code-particles" id="code-particles" aria-hidden="true"></div>
1725{nav}
1726<div class="page">
1727 {watched_bar}
1728 {project_selector}
1729 {content}
1730</div>
1731<footer class="site-footer">
1732 oxide-sloc v{version} — local code metrics workbench ·
1733 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
1734 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
1735 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
1736 · <a href="/report-bug" rel="noopener">Report a Bug</a> · <a href="/api-docs" rel="noopener">REST API</a>
1737</footer>
1738<script nonce="{nonce}">{scripts}</script>
1739</body>
1740</html>"#,
1741 nonce = nonce,
1742 css = css,
1743 nav = nav,
1744 watched_bar = watched_bar,
1745 project_selector = project_selector,
1746 content = content,
1747 version = version,
1748 scripts = scripts,
1749 )
1750}
1751
1752fn build_project_selector(projects: &[String], selected: Option<&str>) -> String {
1756 if projects.is_empty() {
1757 return String::new();
1758 }
1759 use std::fmt::Write as _;
1760 let mut opts = String::new();
1761 let all_sel = if selected.is_none() { " selected" } else { "" };
1762 let _ = write!(
1763 opts,
1764 r#"<option value=""{all_sel}>All projects (latest scan)</option>"#,
1765 );
1766 for p in projects {
1767 let sel = if selected == Some(p.as_str()) {
1768 " selected"
1769 } else {
1770 ""
1771 };
1772 let _ = write!(
1773 opts,
1774 r#"<option value="{v}"{sel}>{v}</option>"#,
1775 v = own_esc(p),
1776 );
1777 }
1778 format!(
1779 r#"<div class="own-project-bar">
1780 <label class="own-project-label" for="own-project-select">Project</label>
1781 <select id="own-project-select" class="own-project-select" aria-label="Select project">{opts}</select>
1782 <span class="own-project-hint">Pick a project to view its latest scan, or all projects.</span>
1783</div>"#,
1784 )
1785}
1786
1787fn own_esc(s: &str) -> String {
1789 s.replace('&', "&")
1790 .replace('<', "<")
1791 .replace('>', ">")
1792 .replace('"', """)
1793}
1794
1795static LANGUAGE_BADGES: &[(&[&str], &str, &str)] = &[
1801 (&["c++", "=cpp"], "C++", "#F34B7D"),
1802 (&["c#", "csharp", "c-sharp"], "C#", "#178600"),
1803 (&["objective"], "ObjC", "#438EFF"),
1804 (&["=c"], "C", "#555555"),
1805 (&["typescript"], "Ts", "#3178C6"),
1806 (&["javascript"], "Js", "#F1E05A"),
1807 (&["rust"], "Rs", "#DEA584"),
1808 (&["python"], "Py", "#3572A5"),
1809 (&["kotlin"], "Kt", "#A97BFF"),
1810 (&["java"], "Jv", "#B07219"),
1811 (&["golang", "=go"], "Go", "#00ADD8"),
1812 (&["ruby"], "Rb", "#701516"),
1813 (&["php"], "Php", "#4F5D95"),
1814 (&["swift"], "Sw", "#F05138"),
1815 (&["scala"], "Sc", "#C22D40"),
1816 (&["shell", "bash"], "Sh", "#89E051"),
1817 (&["powershell"], "Ps", "#012456"),
1818 (&["html"], "Ht", "#E34C26"),
1819 (&["scss", "sass"], "Sa", "#C6538C"),
1820 (&["css"], "Css", "#563D7C"),
1821 (&["haskell"], "Hs", "#5E5086"),
1822 (&["lua"], "Lua", "#000080"),
1823 (&["perl"], "Pl", "#0298C3"),
1824 (&["dart"], "Dt", "#00B4AB"),
1825 (&["elixir"], "Ex", "#6E4A7E"),
1826 (&["erlang"], "Er", "#B83998"),
1827 (&["clojure"], "Cl", "#DB5855"),
1828 (&["ocaml"], "Ml", "#EF7A08"),
1829 (&["f#", "fsharp"], "F#", "#B845FC"),
1830 (&["zig"], "Zig", "#EC915C"),
1831 (&["nim"], "Nim", "#FFC200"),
1832 (&["julia"], "Jl", "#A270BA"),
1833 (&["solidity"], "Sol", "#AA6746"),
1834 (&["sql"], "Sql", "#E38C00"),
1835 (&["docker"], "Dk", "#384D54"),
1836 (&["makefile"], "Mk", "#427819"),
1837 (&["cmake"], "Cm", "#DA3434"),
1838 (&["vue"], "Vue", "#41B883"),
1839 (&["svelte"], "Sv", "#FF3E00"),
1840 (&["assembly"], "Asm", "#6E4C13"),
1841 (&["fortran"], "Fo", "#4D41B1"),
1842 (&["ada"], "Ada", "#02A676"),
1843 (&["groovy"], "Gv", "#4298B8"),
1844 (&["graphql"], "Gql", "#E10098"),
1845 (&["protocol", "protobuf"], "Pb", "#4B7A9C"),
1846 (&["terraform", "hcl"], "Tf", "#844FBA"),
1847 (&["nix"], "Nix", "#7E7EFF"),
1848 (&["verilog"], "V", "#7A9FE8"),
1849 (&["vhdl"], "Vh", "#8892C8"),
1850 (&["visual basic", "vb"], "Vb", "#945DB7"),
1851 (&["pascal", "delphi"], "Pas", "#B0A030"),
1852 (&["crystal"], "Cr", "#333333"),
1853 (&["elm"], "Elm", "#60B5CC"),
1854 (&["tcl"], "Tcl", "#C9A227"),
1855 (&["awk"], "Awk", "#555555"),
1856 (&["glsl", "hlsl"], "Sl", "#5686A5"),
1857 (&["xml", "svg"], "Xml", "#0060AC"),
1858 (&["lisp", "scheme"], "Lsp", "#3FB68B"),
1859 (&["=d"], "D", "#BA595E"),
1860 (&["=r"], "R", "#198CE7"),
1861];
1862
1863fn language_badge_meta(name: &str) -> Option<(&'static str, &'static str)> {
1869 let l = name.to_ascii_lowercase();
1870 LANGUAGE_BADGES.iter().find_map(|(needles, label, color)| {
1871 let hit = needles.iter().any(|n| match n.strip_prefix('=') {
1872 Some(exact) => l == exact,
1873 None => l.contains(n),
1874 });
1875 hit.then_some((*label, *color))
1876 })
1877}
1878
1879fn language_badge(name: &str) -> String {
1882 let (abbr, color): (String, &str) = match language_badge_meta(name) {
1883 Some((a, c)) => (a.to_string(), c),
1884 None => {
1885 let a: String = name
1887 .chars()
1888 .filter(char::is_ascii_alphanumeric)
1889 .take(2)
1890 .collect::<String>()
1891 .to_uppercase();
1892 (a, "#8a756a")
1893 }
1894 };
1895 let txt = {
1897 let h = color.trim_start_matches('#');
1898 let r = u32::from_str_radix(&h[0..2], 16).unwrap_or(120) as f64;
1899 let g = u32::from_str_radix(&h[2..4], 16).unwrap_or(120) as f64;
1900 let b = u32::from_str_radix(&h[4..6], 16).unwrap_or(120) as f64;
1901 if 0.299 * r + 0.587 * g + 0.114 * b > 150.0 {
1902 "#2a2018"
1903 } else {
1904 "#ffffff"
1905 }
1906 };
1907 let fs = if abbr.chars().count() >= 3 {
1908 "7"
1909 } else {
1910 "8.5"
1911 };
1912 format!(
1913 r#"<span class="lang-badge"><svg viewBox="0 0 20 20" width="22" height="22" aria-hidden="true"><rect x="0" y="0" width="20" height="20" rx="5" fill="{color}"></rect><text x="10" y="11" text-anchor="middle" font-family="Inter,ui-sans-serif,sans-serif" font-size="{fs}" font-weight="800" fill="{txt}">{abbr}</text></svg></span>"#,
1914 color = color,
1915 fs = fs,
1916 txt = txt,
1917 abbr = own_esc(&abbr),
1918 )
1919}
1920
1921#[allow(clippy::type_complexity)] fn accumulate_ownership(
1926 run: Option<&AnalysisRun>,
1927) -> (
1928 std::collections::HashMap<u32, u64>,
1929 std::collections::HashMap<u32, [u64; 4]>,
1930) {
1931 let mut files_owned: std::collections::HashMap<u32, u64> = std::collections::HashMap::new();
1932 let mut test_counts: std::collections::HashMap<u32, [u64; 4]> =
1933 std::collections::HashMap::new();
1934 if let Some(r) = run {
1935 for rec in &r.per_file_records {
1936 if let Some(top) = rec.ownership.as_ref().and_then(|o| o.first()) {
1937 *files_owned.entry(top.author_id).or_default() += 1;
1938 }
1939 if rec.is_test_file()
1940 && let Some(own) = rec.ownership.as_ref()
1941 {
1942 for o in own {
1943 let e = test_counts.entry(o.author_id).or_default();
1944 e[0] += o.counts.code_lines;
1945 e[1] += o.counts.comment_lines;
1946 e[2] += o.counts.blank_lines;
1947 e[3] += o.counts.total_lines;
1948 }
1949 }
1950 }
1951 }
1952 (files_owned, test_counts)
1953}
1954
1955fn build_ownership_rows(run: Option<&AnalysisRun>, total_code: u64) -> Vec<OwnershipRow> {
1959 let authors = run.map(|r| r.authors.as_slice()).unwrap_or(&[]);
1960 let remote = run.and_then(|r| r.git_remote_url.as_deref());
1961 let (files_owned, test_counts) = accumulate_ownership(run);
1962 authors
1963 .iter()
1964 .enumerate()
1965 .map(|(i, a)| {
1966 let tc = test_counts.get(&a.id).copied().unwrap_or_default();
1967 OwnershipRow {
1968 name: a.canonical_name.clone(),
1969 email: a.canonical_email.clone(),
1970 profile: author_profile_url(remote, &a.canonical_name, &a.canonical_email),
1971 code: a.counts.code_lines,
1972 comment: a.counts.comment_lines,
1973 blank: a.counts.blank_lines,
1974 total: a.counts.total_lines,
1975 code_pct: if total_code > 0 {
1976 a.counts.code_lines as f64 / total_code as f64 * 100.0
1977 } else {
1978 0.0
1979 },
1980 files_owned: files_owned.get(&a.id).copied().unwrap_or(0),
1981 aliases: a.aliases.len(),
1982 color: OWNERSHIP_PALETTE[i % OWNERSHIP_PALETTE.len()],
1983 test_code: tc[0],
1984 test_comment: tc[1],
1985 test_blank: tc[2],
1986 test_total: tc[3],
1987 }
1988 })
1989 .collect()
1990}
1991
1992fn compute_bus_factor(authors: &[Author], total_code: u64) -> usize {
1994 let mut acc = 0u64;
1995 let mut n = 0usize;
1996 for a in authors {
1997 acc += a.counts.code_lines;
1998 n += 1;
1999 if total_code > 0 && acc * 2 >= total_code {
2000 break;
2001 }
2002 }
2003 n
2004}
2005
2006fn build_language_rows(
2009 run: Option<&AnalysisRun>,
2010 rows: &[OwnershipRow],
2011) -> Vec<(String, u64, String, f64)> {
2012 let mut lang_map: std::collections::HashMap<String, std::collections::HashMap<u32, u64>> =
2013 std::collections::HashMap::new();
2014 if let Some(r) = run {
2015 for rec in &r.per_file_records {
2016 let (Some(lang), Some(ownership)) = (rec.language, rec.ownership.as_ref()) else {
2017 continue;
2018 };
2019 let entry = lang_map.entry(lang.display_name().to_string()).or_default();
2020 for own in ownership {
2021 *entry.entry(own.author_id).or_default() += own.counts.code_lines;
2022 }
2023 }
2024 }
2025 let author_name = |id: u32| -> String {
2026 rows.get(id as usize)
2027 .map(|r| r.name.clone())
2028 .unwrap_or_default()
2029 };
2030 let mut lang_rows: Vec<(String, u64, String, f64)> = lang_map
2031 .into_iter()
2032 .map(|(lang, by_author)| {
2033 let lang_total: u64 = by_author.values().sum();
2034 let (top_id, top_code) = by_author
2035 .iter()
2036 .max_by_key(|(_, c)| **c)
2037 .map(|(id, c)| (*id, *c))
2038 .unwrap_or((0, 0));
2039 let pct = if lang_total > 0 {
2040 top_code as f64 / lang_total as f64 * 100.0
2041 } else {
2042 0.0
2043 };
2044 (lang, lang_total, author_name(top_id), pct)
2045 })
2046 .collect();
2047 lang_rows.sort_by_key(|r| std::cmp::Reverse(r.1));
2048 lang_rows.truncate(15);
2049 lang_rows
2050}
2051
2052fn ownership_data_json(rows: &[OwnershipRow]) -> String {
2054 serde_json::to_string(
2055 &rows
2056 .iter()
2057 .map(|r| {
2058 serde_json::json!({
2059 "name": r.name,
2060 "email": r.email,
2061 "code": r.code,
2062 "comment": r.comment,
2063 "blank": r.blank,
2064 "total": r.total,
2065 "color": r.color,
2066 "profile": r.profile,
2067 "test_code": r.test_code,
2068 "test_comment": r.test_comment,
2069 "test_blank": r.test_blank,
2070 "test_total": r.test_total,
2071 })
2072 })
2073 .collect::<Vec<_>>(),
2074 )
2075 .unwrap_or_else(|_| "[]".to_string())
2076}
2077
2078fn render_ownership_empty(project_label: &str) -> String {
2080 format!(
2081 r#"<div class="panel own-empty">
2082 <svg width="46" height="46" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
2083 <div class="own-empty-title">No ownership data for the latest scan</div>
2084 <p class="muted sx-3ce1b575" >
2085 Code ownership is computed from <strong>git blame</strong> and is <strong>on by default</strong>.
2086 This scan has none — the scanned path is likely not a git repository, or attribution was
2087 turned off for this run. Re-scan a git repo with attribution on to populate this page:
2088 </p>
2089 <pre class="own-code">oxide-sloc analyze {project}</pre>
2090 <p class="muted sx-3ce1b575" >
2091 The scan root must be a git repository. Same-email identities are merged automatically;
2092 cross-account merges are a later step.
2093 </p>
2094</div>"#,
2095 project = own_esc(project_label),
2096 )
2097}
2098
2099fn render_merge_panel(
2102 merge_groups: &[AuthorMergeGroup],
2103 rows: &[OwnershipRow],
2104 redirect_to: &str,
2105) -> String {
2106 use std::fmt::Write as _;
2107 let redirect = own_esc(redirect_to);
2108 let mut merge_checks = String::new();
2109 for r in rows {
2110 let _ = write!(
2111 merge_checks,
2112 r#"<label class="merge-opt"><input type="checkbox" name="email" value="{email}"><span class="merge-opt-dot" data-sx-style="background:{color};"></span><span class="merge-opt-name">{name}</span><span class="merge-opt-email">{email}</span></label>"#,
2113 email = own_esc(&r.email),
2114 color = r.color,
2115 name = own_esc(&r.name),
2116 );
2117 }
2118 let (mailmap_link, mut merge_existing) = if merge_groups.is_empty() {
2121 (String::new(), String::new())
2122 } else {
2123 (
2124 r#"<a href="/code-ownership/mailmap" class="merge-mailmap-link" title="Download a git .mailmap file capturing the merges below">⇩ Download .mailmap</a>"#
2125 .to_string(),
2126 r#"<div class="merge-existing"><div class="merge-existing-title">Active merges</div>"#
2127 .to_string(),
2128 )
2129 };
2130 if !merge_groups.is_empty() {
2131 for g in merge_groups {
2132 let members = g
2133 .members
2134 .iter()
2135 .map(|m| own_esc(m))
2136 .collect::<Vec<_>>()
2137 .join(", ");
2138 let _ = write!(
2139 merge_existing,
2140 r#"<div class="merge-chip"><span class="merge-chip-text"><strong>{name}</strong> {count} emails · {members}</span><form class="sx-5add8e44" method="POST" action="/api/ownership/unmerge" ><input type="hidden" name="canonical_email" value="{cemail}"><input type="hidden" name="redirect_to" value="{redirect}"><button type="submit" class="merge-unmerge">Unmerge</button></form></div>"#,
2141 name = own_esc(&g.canonical_name),
2142 count = g.members.len(),
2143 members = members,
2144 cemail = own_esc(&g.canonical_email),
2145 redirect = redirect,
2146 );
2147 }
2148 merge_existing.push_str(
2150 r#"<p class="merge-mailmap-note"><strong>What is <code>.mailmap</code>?</strong> It is a small text file that git reads from your repository root. Downloading it and committing it as <code>.mailmap</code> makes git itself — and every future oxide-sloc scan — permanently treat the combined names and emails above as one contributor. That means <code>git shortlog</code>, <code>git blame</code>, and re-scans all show the merged identity automatically, so you never have to redo these merges. Without it, the merges above live only in this app.</p>"#,
2151 );
2152 merge_existing.push_str("</div>");
2153 }
2154 format!(
2155 r#"<div class="section-header">Combine contributors</div>
2156<div class="panel">
2157 <p class="muted sx-16dbf2a3" >Same person committing under different names or emails? Select two or more contributors and merge them into a single identity. This is applied on top of the scan — <strong>no re-scan needed</strong> — and can be exported as a git <code>.mailmap</code> so the merge carries into git itself and future scans.</p>
2158 <form method="POST" action="/api/ownership/merge">
2159 <input type="hidden" name="redirect_to" value="{redirect}">
2160 <div class="merge-grid">{merge_checks}</div>
2161 <div class="merge-controls">
2162 <input type="text" name="canonical_name" class="merge-name-input" placeholder="Canonical display name (optional)">
2163 <button type="submit" class="merge-btn">Merge selected</button>
2164 {mailmap_link}
2165 </div>
2166 </form>
2167 {merge_existing}
2168</div>
2169
2170"#,
2171 redirect = redirect,
2172 merge_checks = merge_checks,
2173 mailmap_link = mailmap_link,
2174 merge_existing = merge_existing,
2175 )
2176}
2177
2178#[allow(clippy::too_many_arguments)]
2183fn render_ownership_populated(
2184 rows: &[OwnershipRow],
2185 bus_factor: usize,
2186 total_code: u64,
2187 lang_rows: &[(String, u64, String, f64)],
2188 data_json: &str,
2189 merge_groups: &[AuthorMergeGroup],
2190 nonce: &str,
2191 project_label: &str,
2192) -> String {
2193 use std::fmt::Write as _;
2194 let name_link = |r: &OwnershipRow| -> String {
2197 let name = own_esc(&r.name);
2198 match r.profile.as_deref() {
2199 Some(url) => format!(
2200 r#"<a class="own-profile-link" href="{url}" target="_blank" rel="noopener" title="View {name}'s profile / contributions">{name}</a>"#,
2201 url = own_esc(url),
2202 name = name,
2203 ),
2204 None => name,
2205 }
2206 };
2207 let top_owner = rows.first();
2208 let top_name = top_owner.map(&name_link).unwrap_or_default();
2209 let top_pct = top_owner.map(|r| r.code_pct).unwrap_or(0.0);
2210
2211 let max_code = rows.iter().map(|r| r.code).max().unwrap_or(1).max(1);
2214 let mut bars = String::new();
2215 for r in rows {
2216 let pct = (r.code as f64 / max_code as f64 * 100.0).round() as u64;
2217 let _ = write!(
2218 bars,
2219 r#"<div class="own-bar-row"><div class="own-bar-name" title="{email}">{name}</div><div class="own-bar-track"><div class="own-bar-fill" data-sx-style="width:{pct}%;background:{color};"></div></div><div class="own-bar-val">{val}</div></div>"#,
2220 email = own_esc(&r.email),
2221 name = name_link(r),
2222 pct = pct,
2223 color = r.color,
2224 val = fmt_num(r.code as i64),
2225 );
2226 }
2227
2228 let mut author_table = String::new();
2229 for r in rows {
2230 let dev_code = r.code.saturating_sub(r.test_code);
2231 let _ = write!(
2232 author_table,
2233 r#"<tr><td><span class="own-dot" data-sx-style="background:{color};"></span>{name}</td><td><button type="button" class="own-email-copy" data-email="{email}" title="Click to copy this email">{email}</button></td><td class="num">{code}</td><td class="num">{dev_code}</td><td class="num">{test_code}</td><td class="num">{comment}</td><td class="num">{blank}</td><td class="num">{total}</td><td class="num">{pct:.1}%</td><td class="num">{files}</td><td class="num">{aliases}</td></tr>"#,
2234 color = r.color,
2235 name = name_link(r),
2236 email = own_esc(&r.email),
2237 code = fmt_num(r.code as i64),
2238 dev_code = fmt_num(dev_code as i64),
2239 test_code = fmt_num(r.test_code as i64),
2240 comment = fmt_num(r.comment as i64),
2241 blank = fmt_num(r.blank as i64),
2242 total = fmt_num(r.total as i64),
2243 pct = r.code_pct,
2244 files = r.files_owned,
2245 aliases = r.aliases,
2246 );
2247 }
2248
2249 let mut lang_table = String::new();
2250 for (lang, code, owner, pct) in lang_rows {
2251 let _ = write!(
2252 lang_table,
2253 r#"<tr><td><span class="lang-cell">{badge}<span class="lang-cell-name">{lang}</span></span></td><td class="num">{code}</td><td>{owner}</td><td class="num">{pct:.1}%</td></tr>"#,
2254 badge = language_badge(lang),
2255 lang = own_esc(lang),
2256 code = fmt_num(*code as i64),
2257 owner = own_esc(owner),
2258 pct = pct,
2259 );
2260 }
2261
2262 let merge_panel = render_merge_panel(merge_groups, rows, "/code-ownership");
2263
2264 let total_comment: u64 = rows.iter().map(|r| r.comment).sum();
2266 let total_test_code: u64 = rows.iter().map(|r| r.test_code).sum();
2267 let total_dev_code = total_code.saturating_sub(total_test_code);
2268 let test_pct = if total_code > 0 {
2269 total_test_code as f64 / total_code as f64 * 100.0
2270 } else {
2271 0.0
2272 };
2273 let intro = format!(
2276 r#"<p class="muted own-page-intro">Per-author line ownership for <strong>{project}</strong>, derived from git blame. Filter the chart by line category; the table breaks down code, comments, and blanks per contributor.</p>"#,
2277 project = own_esc(project_label),
2278 );
2279
2280 format!(
2281 r#"<div class="summary-strip">
2282 <div class="stat-chip"><div class="stat-chip-val">{contributors}</div><div class="stat-chip-label">Contributors</div><span class="stat-chip-tip">Distinct authors who own at least one line in this scan, after identity merges are applied.</span></div>
2283 <div class="stat-chip"><div class="stat-chip-val">{top_name}</div><div class="stat-chip-label">Top Owner · {top_pct:.0}% of code</div><span class="stat-chip-tip">The single contributor who owns the largest share of code lines, and what percentage of all code that is. Click the name to open their profile.</span></div>
2284 <div class="stat-chip"><div class="stat-chip-val">{bus_factor}</div><div class="stat-chip-label">Bus Factor (owners of 50% code)</div><span class="stat-chip-tip">The fewest contributors who together own at least half of the code. A low number means knowledge is concentrated in very few people — a project risk.</span></div>
2285 <div class="stat-chip"><div class="stat-chip-val">{total_code}</div><div class="stat-chip-label">Total Code Lines</div><span class="stat-chip-tip">Total physical code lines attributed across all contributors. Comments and blank lines are excluded from this figure.</span></div>
2286 <div class="stat-chip"><div class="stat-chip-val">{dev_code}</div><div class="stat-chip-label">Development Code</div><span class="stat-chip-tip">Code lines owned in non-test files. This is total code minus code that lives in files detected as tests.</span></div>
2287 <div class="stat-chip"><div class="stat-chip-val">{test_code}</div><div class="stat-chip-label">Test Code · {test_pct:.0}% of code</div><span class="stat-chip-tip">Code lines owned in files classified as tests (detected from test functions/assertions or a test-path convention). A healthy share suggests good test coverage effort.</span></div>
2288 <div class="stat-chip"><div class="stat-chip-val">{total_comment}</div><div class="stat-chip-label">Total Comment Lines</div><span class="stat-chip-tip">Physical comment / documentation lines attributed across all contributors.</span></div>
2289</div>
2290
2291{intro}
2292
2293<div class="section-header">Contributor breakdown</div>
2294<p class="muted own-charts-sub">Interactive charts for this scan — hover a legend entry to isolate it, click a bar or slice to open that contributor's profile, and use <strong>Full View</strong> for a larger chart. These follow the ownership scope filter below.</p>
2295<div class="own-charts-grid">
2296 <div class="chart-box">
2297 <div class="toolbar"><div class="toolbar-left"><span class="chart-box-title">Line composition per contributor</span></div><button type="button" class="chart-expand-btn" id="own-comp-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button></div>
2298 <div class="own-canvas-wrap" id="own-comp-wrap"><canvas id="own-canvas-composition"></canvas></div>
2299 <div class="own-legend" id="own-comp-legend"></div>
2300 <div class="own-canvas-note" id="own-comp-note" hidden></div>
2301 <div class="own-canvas-empty" id="own-comp-empty" hidden>No contributor data to chart.</div>
2302 </div>
2303 <div class="chart-box">
2304 <div class="toolbar"><div class="toolbar-left"><span class="chart-box-title">Share of codebase (code lines)</span></div><button type="button" class="chart-expand-btn" id="own-share-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button></div>
2305 <div class="own-canvas-wrap own-canvas-wrap-donut" id="own-share-wrap"><canvas id="own-canvas-share"></canvas></div>
2306 <div class="own-legend" id="own-share-legend"></div>
2307 <div class="own-canvas-note" id="own-share-note" hidden></div>
2308 <div class="own-canvas-empty" id="own-share-empty" hidden>No contributor data to chart.</div>
2309 </div>
2310</div>
2311
2312<div class="chart-box">
2313 <div class="own-chart-head">
2314 <div class="chart-box-title">Lines owned per contributor</div>
2315 <div class="own-control-groups">
2316 <div class="own-controls" id="own-scope-controls" role="group" aria-label="Ownership scope">
2317 <button type="button" class="own-scope active" data-scope="all">All files</button>
2318 <button type="button" class="own-scope" data-scope="dev">Development</button>
2319 <button type="button" class="own-scope" data-scope="test">Tests</button>
2320 </div>
2321 <div class="own-controls" id="own-metric-controls" role="group" aria-label="Line category">
2322 <button type="button" class="own-filter active" data-metric="code">Code</button>
2323 <button type="button" class="own-filter" data-metric="comment">Comment</button>
2324 <button type="button" class="own-filter" data-metric="blank">Blank</button>
2325 <button type="button" class="own-filter" data-metric="total">Total</button>
2326 </div>
2327 </div>
2328 </div>
2329 <div id="own-bars">{bars}</div>
2330</div>
2331
2332<div class="section-header">Contributors</div>
2333<p class="own-table-hint">Click a contributor's <strong>name</strong> to open their profile / contributions on the hosting platform; click an <strong>email</strong> to copy it. <em>Dev</em> and <em>Test</em> split code lines by whether the owning file is a test.</p>
2334<div class="panel sx-e6a29e9c" >
2335 <div class="own-table-scroll">
2336 <table class="data-table own-contrib-table" id="own-contrib-table">
2337 <thead><tr><th data-sort="text">Author</th><th data-sort="text">Email</th><th class="num" data-sort="num">Code</th><th class="num" data-sort="num">Dev Code</th><th class="num" data-sort="num">Test Code</th><th class="num" data-sort="num">Comment</th><th class="num" data-sort="num">Blank</th><th class="num" data-sort="num">Total</th><th class="num" data-sort="num">Code %</th><th class="num" data-sort="num">Files Owned</th><th class="num" data-sort="num">Aliases</th></tr></thead>
2338 <tbody>{author_table}</tbody>
2339 </table>
2340 </div>
2341</div>
2342
2343<div class="section-header">Ownership by language</div>
2344<div class="panel sx-e6a29e9c" >
2345 <table class="data-table">
2346 <thead><tr><th>Language</th><th class="num">Code Lines</th><th>Top Owner</th><th class="num">Owner %</th></tr></thead>
2347 <tbody>{lang_table}</tbody>
2348 </table>
2349</div>
2350
2351{merge_panel}
2352<div class="panel own-footnote">
2353 <p class="muted sx-8d842990" >Ownership reflects the author who last touched each physical line (<code>git blame -w -M -C</code>, <code>.mailmap</code> honoured). Counts are physical lines and sum to the file's line total; they can differ slightly from the policy-adjusted SLOC totals elsewhere. Same-email identities are auto-merged; use <strong>Combine contributors</strong> above to merge across different emails. Test vs. development lines are split by classifying each <em>file</em> as a test (via detected test functions/assertions or a test-path convention), then attributing that file's owned lines accordingly.</p>
2354</div>
2355
2356<div class="own-copy-toast" id="own-copy-toast" role="status" aria-live="polite" hidden>Email copied</div>
2357<script id="own-data" type="application/json" nonce="{nonce}">{data_json}</script>"#,
2358 contributors = rows.len(),
2359 intro = intro,
2360 top_name = top_name,
2361 top_pct = top_pct,
2362 bus_factor = bus_factor,
2363 total_code = fmt_num(total_code as i64),
2364 dev_code = fmt_num(total_dev_code as i64),
2365 test_code = fmt_num(total_test_code as i64),
2366 test_pct = test_pct,
2367 total_comment = fmt_num(total_comment as i64),
2368 bars = bars,
2369 author_table = author_table,
2370 lang_table = lang_table,
2371 merge_panel = merge_panel,
2372 nonce = nonce,
2373 data_json = data_json,
2374 )
2375}
2376
2377#[allow(clippy::too_many_lines)] fn build_router(state: AppState) -> Router {
2379 let protected = Router::new()
2380 .route("/", get(splash))
2381 .route("/scan-setup", get(scan_setup_handler))
2382 .route("/scan", get(index))
2383 .route("/analyze", post(analyze_handler))
2384 .route("/preview", get(preview_handler))
2385 .route("/api/suggest-coverage", get(api_suggest_coverage))
2386 .route("/api/attribution-estimate", get(api_attribution_estimate))
2387 .route("/pick-directory", get(pick_directory_handler))
2388 .route("/open-path", get(open_path_handler))
2389 .route("/pick-file", get(pick_file_handler))
2390 .route(
2391 "/api/upload-directory",
2392 post(upload_directory_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
2393 )
2394 .route(
2395 "/api/upload-file",
2396 post(upload_file_handler).layer(DefaultBodyLimit::max(30 * 1024 * 1024)),
2397 )
2398 .route(
2399 "/api/upload-tarball",
2400 post(upload_tarball_handler)
2403 .layer(DefaultBodyLimit::max(tarball_http_body_limit_bytes())),
2404 )
2405 .route("/locate-report", post(locate_report_handler))
2406 .route("/locate-reports-dir", post(locate_reports_dir_handler))
2407 .route("/relocate-scan", post(relocate_scan_handler))
2408 .route("/watched-dirs/add", post(add_watched_dir_handler))
2409 .route("/watched-dirs/remove", post(remove_watched_dir_handler))
2410 .route("/watched-dirs/refresh", post(refresh_watched_dirs_handler))
2411 .route("/view-reports", get(history_handler))
2412 .route("/compare-scans", get(compare_select_handler))
2413 .route("/compare", get(compare_handler))
2414 .route("/multi-compare", get(multi_compare_handler))
2415 .route("/images/{folder}/{file}", get(image_handler))
2416 .route("/runs/{artifact}/{run_id}", get(artifact_handler))
2417 .route("/api/metrics/latest", get(api_metrics_latest_handler))
2418 .route("/api/metrics/{run_id}", get(api_metrics_run_handler))
2419 .route("/api/metrics/history", get(api_metrics_history_handler))
2420 .route("/api/metrics/churn", get(api_metrics_churn_handler))
2421 .route(
2422 "/api/metrics/submodules",
2423 get(api_metrics_submodules_handler),
2424 )
2425 .route("/api/ingest", post(api_ingest_handler))
2426 .route("/api/project-history", get(project_history_handler))
2427 .route("/trend-reports", get(trend_report_handler))
2428 .route("/test-metrics", get(test_metrics_handler))
2429 .route("/code-ownership", get(code_ownership_handler))
2430 .route("/code-ownership/mailmap", get(ownership_mailmap_handler))
2431 .route("/api/ownership/merge", post(ownership_merge_handler))
2432 .route("/api/ownership/unmerge", post(ownership_unmerge_handler))
2433 .route("/api/runs/{wait_id}/status", get(async_run_status_handler))
2434 .route("/api/runs/{wait_id}/cancel", post(cancel_run_handler))
2435 .route("/api/runs/{run_id}/pdf-status", get(pdf_status_handler))
2436 .route("/runs/result/{run_id}", get(async_run_result_handler))
2437 .route("/embed/summary", get(embed_handler))
2438 .route("/git-browser", get(git_browser::git_browser_handler))
2440 .route("/api/git/refs", get(git_browser::api_list_refs))
2441 .route("/api/git/scan-ref", get(git_browser::api_scan_ref))
2442 .route("/api/git/compare-refs", get(git_browser::api_compare_refs))
2443 .route(
2448 "/export/pdf",
2449 post(export_pdf_handler).layer(DefaultBodyLimit::max(64 * 1024 * 1024)),
2450 )
2451 .route("/export-config", get(export_config_handler))
2453 .route("/import-config", post(import_config_handler))
2454 .route("/api/scan-profiles", get(api_list_scan_profiles))
2456 .route("/api/scan-profiles", post(api_save_scan_profile))
2457 .route(
2458 "/api/scan-profiles/{id}",
2459 axum::routing::delete(api_delete_scan_profile),
2460 )
2461 .route("/report-bug", get(report_bug::report_bug_handler))
2463 .route("/integrations", get(integrations::integrations_handler))
2465 .route(
2466 "/webhook-setup",
2467 get(|| async { axum::response::Redirect::permanent("/integrations") }),
2468 )
2469 .route(
2470 "/confluence-setup",
2471 get(|| async { axum::response::Redirect::permanent("/integrations#confluence") }),
2472 )
2473 .route("/api/schedules", get(git_webhook::api_list_schedules))
2474 .route("/api/schedules", post(git_webhook::api_create_schedule))
2475 .route(
2476 "/api/schedules",
2477 axum::routing::delete(git_webhook::api_delete_schedule),
2478 )
2479 .route(
2480 "/api/confluence/config",
2481 get(confluence::api_get_confluence_config),
2482 )
2483 .route(
2484 "/api/confluence/config",
2485 post(confluence::api_save_confluence_config),
2486 )
2487 .route(
2488 "/api/confluence/test",
2489 post(confluence::api_test_confluence),
2490 )
2491 .route(
2492 "/api/confluence/post",
2493 post(confluence::api_post_to_confluence),
2494 )
2495 .route(
2496 "/api/confluence/wiki-markup",
2497 get(confluence::api_wiki_markup),
2498 )
2499 .route("/api/runs/{run_id}/bundle", get(download_bundle_handler))
2501 .route("/api/runs/{run_id}/export", post(export_run_handler))
2502 .route(
2503 "/api/runs/{run_id}",
2504 axum::routing::delete(delete_run_handler),
2505 )
2506 .route("/api/runs/cleanup", post(cleanup_runs_handler))
2507 .route(
2509 "/api/cleanup-policy",
2510 get(api_get_cleanup_policy)
2511 .post(api_save_cleanup_policy)
2512 .delete(api_delete_cleanup_policy),
2513 )
2514 .route("/api/cleanup-policy/run-now", post(api_run_cleanup_now))
2515 .route("/api/admin/config", get(api_admin_config))
2517 .route("/api-docs", get(api_docs_handler))
2519 .route("/metrics", get(metrics_handler))
2521 .route_layer(middleware::from_fn_with_state(
2522 state.clone(),
2523 auth::require_api_key,
2524 ));
2525
2526 protected
2527 .route("/healthz", get(healthz))
2528 .route("/readyz", get(readyz))
2529 .route("/api/health", get(api_health_handler))
2530 .route("/api/version", get(api_version_handler))
2531 .route("/api/connectivity", get(connectivity::connectivity_handler))
2534 .route("/api/openapi.yaml", get(openapi_yaml_handler))
2535 .route("/llms.txt", get(llms_txt_handler))
2536 .route("/llms-full.txt", get(llms_full_txt_handler))
2537 .route("/badge/{metric}", get(badge_handler))
2538 .route("/static/chart.js", get(chart_js_handler))
2539 .route("/static/chart-report.js", get(report_chart_js_handler))
2540 .route("/static/app.css", get(app_css_handler))
2541 .route("/static/app.js", get(app_js_handler))
2542 .route("/auth/login", get(auth::auth_login_get))
2543 .route("/auth/login", post(auth::auth_login_post))
2544 .route("/auth/logout", post(auth::auth_logout))
2545 .route("/auth/consent", get(auth::auth_consent_accept))
2547 .route(
2550 "/webhooks/github",
2551 post(git_webhook::handle_github_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
2552 )
2553 .route(
2554 "/webhooks/gitlab",
2555 post(git_webhook::handle_gitlab_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
2556 )
2557 .route(
2558 "/webhooks/bitbucket",
2559 post(git_webhook::handle_bitbucket_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
2560 )
2561 .route(
2564 "/webhooks/ci",
2565 post(git_webhook::handle_ci_webhook).layer(DefaultBodyLimit::max(512 * 1024)),
2566 )
2567 .layer(middleware::from_fn_with_state(state.clone(), rate_limit))
2568 .layer(middleware::from_fn(consent_gate))
2569 .layer(middleware::from_fn(csrf_protect))
2570 .layer(middleware::from_fn_with_state(
2571 state.clone(),
2572 host_allowlist_guard,
2573 ))
2574 .layer(middleware::from_fn_with_state(
2575 state.clone(),
2576 add_security_headers,
2577 ))
2578 .layer(build_cors_layer(state.server_mode))
2579 .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
2580 .layer(middleware::from_fn(compress_response))
2582 .layer(tower_http::timeout::TimeoutLayer::with_status_code(
2585 axum::http::StatusCode::REQUEST_TIMEOUT,
2586 http_timeout(),
2587 ))
2588 .with_state(state)
2589}
2590
2591fn http_timeout() -> std::time::Duration {
2596 let secs = std::env::var("SLOC_HTTP_TIMEOUT_SECS")
2597 .ok()
2598 .and_then(|s| s.trim().parse::<u64>().ok())
2599 .unwrap_or(600);
2600 std::time::Duration::from_secs(if secs == 0 { 86_400 } else { secs })
2601}
2602
2603const COMPRESS_MIN_BYTES: u64 = 1024;
2611const COMPRESS_MAX_BYTES: u64 = 32 * 1024 * 1024;
2613
2614fn client_accepts_gzip(headers: &axum::http::HeaderMap) -> bool {
2616 headers
2617 .get(header::ACCEPT_ENCODING)
2618 .and_then(|v| v.to_str().ok())
2619 .is_some_and(|val| {
2620 val.split(',').any(|enc| {
2621 enc.split(';')
2622 .next()
2623 .unwrap_or("")
2624 .trim()
2625 .eq_ignore_ascii_case("gzip")
2626 })
2627 })
2628}
2629
2630fn is_compressible_type(content_type: &str) -> bool {
2633 let ct = content_type
2634 .split(';')
2635 .next()
2636 .unwrap_or("")
2637 .trim()
2638 .to_ascii_lowercase();
2639 ct.starts_with("text/")
2640 || matches!(
2641 ct.as_str(),
2642 "application/json"
2643 | "application/javascript"
2644 | "application/xml"
2645 | "application/yaml"
2646 | "application/manifest+json"
2647 | "image/svg+xml"
2648 )
2649}
2650
2651async fn compress_response(req: Request<Body>, next: Next) -> Response {
2653 let accepts_gzip = client_accepts_gzip(req.headers());
2654 let resp = next.run(req).await;
2655 if !accepts_gzip || resp.headers().contains_key(header::CONTENT_ENCODING) {
2657 return resp;
2658 }
2659 let content_type = resp
2660 .headers()
2661 .get(header::CONTENT_TYPE)
2662 .and_then(|v| v.to_str().ok())
2663 .unwrap_or("")
2664 .to_owned();
2665 if !is_compressible_type(&content_type) {
2666 return resp;
2667 }
2668
2669 let (mut parts, body) = resp.into_parts();
2670 let eligible = matches!(
2673 http_body::Body::size_hint(&body).exact(),
2674 Some(n) if (COMPRESS_MIN_BYTES..=COMPRESS_MAX_BYTES).contains(&n)
2675 );
2676 if !eligible {
2677 return Response::from_parts(parts, body);
2678 }
2679
2680 let bytes = match axum::body::to_bytes(body, COMPRESS_MAX_BYTES as usize).await {
2681 Ok(b) => b,
2682 Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
2684 };
2685
2686 use std::io::Write as _;
2687 let mut encoder = flate2::write::GzEncoder::new(
2688 Vec::with_capacity(bytes.len() / 2),
2689 flate2::Compression::default(),
2690 );
2691 if encoder.write_all(&bytes).is_err() {
2692 return Response::from_parts(parts, Body::from(bytes));
2693 }
2694 let compressed = match encoder.finish() {
2695 Ok(c) => c,
2696 Err(_) => return Response::from_parts(parts, Body::from(bytes)),
2697 };
2698
2699 parts.headers.remove(header::CONTENT_LENGTH);
2700 parts
2701 .headers
2702 .insert(header::CONTENT_LENGTH, HeaderValue::from(compressed.len()));
2703 parts
2704 .headers
2705 .insert(header::CONTENT_ENCODING, HeaderValue::from_static("gzip"));
2706 parts
2707 .headers
2708 .append(header::VARY, HeaderValue::from_static("accept-encoding"));
2709 Response::from_parts(parts, Body::from(compressed))
2710}
2711
2712pub const TEST_SERVER_MODE_API_KEY: &str = "oxide-sloc-test-server-mode-internal-key";
2715
2716fn test_app_state(tmp_subdir: &str) -> AppState {
2722 static TEST_DIR_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
2729 unsafe { std::env::set_var("SLOC_HEADLESS", "1") };
2731 let seq = TEST_DIR_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2732 let tmp = std::env::temp_dir().join(format!("{tmp_subdir}-{}-{seq}", std::process::id()));
2733 AppState {
2734 base_config: AppConfig::default(),
2735 artifacts: Arc::new(Mutex::new(HashMap::new())),
2736 async_runs: Arc::new(Mutex::new(HashMap::new())),
2737 registry: Arc::new(Mutex::new(ScanRegistry::default())),
2738 registry_path: tmp.join("registry.json"),
2739 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
2740 server_mode: false,
2741 allow_unauthenticated: false,
2742 tls_enabled: false,
2743 api_keys: Arc::new(vec![]),
2744 readonly_api_keys: Arc::new(vec![]),
2745 rate_limiter: Arc::new(IpRateLimiter::new(
2746 Duration::from_mins(1),
2747 600,
2748 10,
2749 Duration::from_hours(1),
2750 )),
2751 trust_proxy: false,
2752 trusted_proxy_ips: vec![],
2753 git_clones_dir: tmp.join("git-clones"),
2754 schedules: Arc::new(Mutex::new(ScheduleStore::default())),
2755 schedules_path: tmp.join("schedules.json"),
2756 scan_profiles: Arc::new(Mutex::new(ScanProfileStore::default())),
2757 scan_profiles_path: tmp.join("scan_profiles.json"),
2758 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
2759 confluence: Arc::new(Mutex::new(confluence::ConfluenceConfigStore::default())),
2760 confluence_path: tmp.join("confluence_config.json"),
2761 watched_dirs: Arc::new(Mutex::new(WatchedDirsStore::default())),
2762 watched_dirs_path: tmp.join("watched_dirs.json"),
2763 cleanup_policy: Arc::new(Mutex::new(CleanupPolicyStore::default())),
2764 cleanup_policy_path: tmp.join("cleanup_policy.json"),
2765 cleanup_task_handle: Arc::new(Mutex::new(None)),
2766 }
2767}
2768
2769pub fn make_test_router() -> Router {
2771 build_router(test_app_state("sloc_test"))
2772}
2773
2774pub fn make_test_router_with_key(api_key: &str) -> Router {
2776 let mut state = test_app_state("sloc_test_key");
2777 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]);
2778 build_router(state)
2779}
2780
2781pub fn make_test_router_with_readonly_key(full_key: &str, readonly_key: &str) -> Router {
2787 let mut state = test_app_state("sloc_test_readonly");
2788 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(full_key.to_owned()))]);
2789 state.readonly_api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
2790 readonly_key.to_owned(),
2791 ))]);
2792 build_router(state)
2793}
2794
2795pub fn make_test_router_server_mode() -> Router {
2799 let mut state = test_app_state("sloc_test_server");
2800 state.server_mode = true;
2801 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
2802 TEST_SERVER_MODE_API_KEY.to_owned(),
2803 ))]);
2804 build_router(state)
2805}
2806
2807pub fn make_test_router_server_mode_with_roots(roots: Vec<PathBuf>) -> Router {
2813 let mut state = test_app_state("sloc_test_server_roots");
2814 state.server_mode = true;
2815 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(
2816 TEST_SERVER_MODE_API_KEY.to_owned(),
2817 ))]);
2818 state.base_config.discovery.allowed_scan_roots = roots;
2819 build_router(state)
2820}
2821
2822pub fn make_test_router_exhausted_semaphore() -> Router {
2825 let mut state = test_app_state("sloc_test_exhaust");
2826 state.analyze_semaphore = Arc::new(tokio::sync::Semaphore::new(0));
2827 build_router(state)
2828}
2829
2830pub fn make_test_router_tight_rate_limit() -> Router {
2833 let mut state = test_app_state("sloc_test_rate");
2834 state.rate_limiter = Arc::new(IpRateLimiter::new(
2835 Duration::from_mins(1),
2836 2,
2837 5,
2838 Duration::from_secs(5),
2839 ));
2840 build_router(state)
2841}
2842
2843pub fn make_test_router_tight_auth_lockout(api_key: &str) -> Router {
2846 let mut state = test_app_state("sloc_test_auth_lockout");
2847 state.api_keys = Arc::new(vec![secrecy::SecretBox::new(Box::new(api_key.to_owned()))]);
2848 state.rate_limiter = Arc::new(IpRateLimiter::new(
2849 Duration::from_mins(1),
2850 600,
2851 2, Duration::from_millis(200), ));
2854 build_router(state)
2855}
2856
2857struct RuntimeSecurityConfig {
2858 api_keys: Vec<secrecy::SecretBox<String>>,
2859 readonly_api_keys: Vec<secrecy::SecretBox<String>>,
2860 tls_cert: Option<String>,
2861 tls_key: Option<String>,
2862 tls_enabled: bool,
2863 trust_proxy: bool,
2864 trusted_proxy_ips: Vec<IpAddr>,
2865 rate_limiter: Arc<IpRateLimiter>,
2866}
2867
2868fn allow_unauthenticated_server_mode() -> bool {
2871 matches!(
2872 std::env::var("SLOC_ALLOW_UNAUTHENTICATED").as_deref(),
2873 Ok("1" | "true" | "TRUE")
2874 )
2875}
2876
2877fn refuse_unauthenticated_server(server_mode: bool, has_api_keys: bool) -> bool {
2881 server_mode && !has_api_keys && !allow_unauthenticated_server_mode()
2882}
2883
2884fn bind_is_network_facing(bind_address: &str) -> bool {
2893 if let Ok(addr) = bind_address.parse::<SocketAddr>() {
2894 return !addr.ip().is_loopback();
2895 }
2896 let host = bind_address
2899 .rsplit_once(':')
2900 .map_or(bind_address, |(h, _)| h)
2901 .trim_matches(|c| c == '[' || c == ']');
2902 if let Ok(ip) = host.parse::<IpAddr>() {
2903 return !ip.is_loopback();
2904 }
2905 !host.eq_ignore_ascii_case("localhost")
2906}
2907
2908fn hardened_mode() -> bool {
2913 std::env::var("SLOC_HARDENED").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
2914}
2915
2916fn require_tls() -> bool {
2920 hardened_mode()
2921 || std::env::var("SLOC_REQUIRE_TLS")
2922 .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
2923}
2924
2925pub(crate) fn session_idle_timeout() -> Option<Duration> {
2931 match std::env::var("SLOC_SESSION_IDLE_SECS")
2932 .ok()
2933 .and_then(|v| v.parse::<u64>().ok())
2934 {
2935 Some(0) => None,
2936 Some(secs) => Some(Duration::from_secs(secs)),
2937 None if hardened_mode() => Some(Duration::from_mins(15)),
2938 None => None,
2939 }
2940}
2941
2942const DEFAULT_CONSENT_NOTICE: &str = "This is a restricted system for authorized users only. \
2945Activity on this system may be monitored and recorded. By continuing you acknowledge that you \
2946are an authorized user and consent to such monitoring. Unauthorized use is prohibited.";
2947
2948fn consent_banner_text() -> Option<String> {
2952 if let Ok(t) = std::env::var("SLOC_CONSENT_BANNER") {
2953 let t = t.trim();
2954 if !t.is_empty() {
2955 return Some(t.to_owned());
2956 }
2957 }
2958 hardened_mode().then(|| DEFAULT_CONSENT_NOTICE.to_owned())
2959}
2960
2961fn consent_gate_applies(req: &Request<Body>) -> bool {
2965 const EXEMPT: &[&str] = &[
2966 "/auth/consent",
2967 "/static/",
2968 "/images/",
2969 "/assets/",
2970 "/badge/",
2971 "/healthz",
2972 "/api/",
2973 "/webhooks/",
2974 "/metrics",
2975 "/favicon",
2976 "/llms",
2977 ];
2978 if !matches!(
2979 *req.method(),
2980 axum::http::Method::GET | axum::http::Method::HEAD
2981 ) {
2982 return false;
2983 }
2984 let is_html = req
2985 .headers()
2986 .get(header::ACCEPT)
2987 .and_then(|v| v.to_str().ok())
2988 .is_some_and(|a| a.contains("text/html"));
2989 if !is_html {
2990 return false;
2991 }
2992 let path = req.uri().path();
2993 !EXEMPT.iter().any(|p| path.starts_with(p))
2994}
2995
2996fn request_has_consent(req: &Request<Body>) -> bool {
2998 req.headers()
2999 .get(header::COOKIE)
3000 .and_then(|v| v.to_str().ok())
3001 .is_some_and(|c| c.split(';').any(|p| p.trim() == "sloc_consent=1"))
3002}
3003
3004async fn consent_gate(req: Request<Body>, next: Next) -> Response {
3008 let Some(text) = consent_banner_text() else {
3009 return next.run(req).await;
3010 };
3011 if !consent_gate_applies(&req) || request_has_consent(&req) {
3012 return next.run(req).await;
3013 }
3014 let nonce = req
3015 .extensions()
3016 .get::<CspNonce>()
3017 .map(|c| c.0.clone())
3018 .unwrap_or_default();
3019 let next_path = req.uri().path_and_query().map_or("/", |pq| pq.as_str());
3020 render_consent_page(&text, next_path, &nonce)
3021}
3022
3023fn html_escape_consent(s: &str) -> String {
3025 s.replace('&', "&")
3026 .replace('<', "<")
3027 .replace('>', ">")
3028 .replace('"', """)
3029}
3030
3031fn render_consent_page(text: &str, next_path: &str, nonce: &str) -> Response {
3034 let safe_next = if next_path.starts_with('/')
3036 && !next_path.starts_with("//")
3037 && !next_path.contains("://")
3038 && !next_path.starts_with("/auth/")
3039 {
3040 next_path
3041 } else {
3042 "/"
3043 };
3044 let accept_url = format!("/auth/consent?next={}", html_escape_consent(safe_next));
3045 let body = format!(
3046 r#"<!doctype html><html><head><meta charset="utf-8">
3047<meta name="viewport" content="width=device-width, initial-scale=1">
3048<title>Notice and Consent — OxideSLOC</title>
3049<style nonce="{nonce}">body{{font-family:system-ui,sans-serif;max-width:560px;margin:64px auto;padding:0 24px;color:#2f241c}}
3050h1{{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}}
3051.agree{{display:inline-block;margin-top:20px;background:#b85d33;color:#fff;text-decoration:none;padding:10px 22px;border-radius:8px;font-weight:700}}
3052.agree:hover{{background:#a04d27}}</style>
3053</head><body>
3054<h1>Notice and Consent</h1>
3055<div class="notice">{}</div>
3056<a class="agree" href="{}">I Agree</a>
3057</body></html>"#,
3058 html_escape_consent(text),
3059 accept_url,
3060 nonce = nonce
3061 );
3062 (StatusCode::OK, Html(body)).into_response()
3063}
3064
3065fn host_of_url(url: &str) -> Option<String> {
3070 let after_scheme = url.split("://").nth(1).unwrap_or(url);
3071 let authority = after_scheme
3072 .split(['/', '?', '#'])
3073 .next()
3074 .unwrap_or("")
3075 .trim();
3076 let authority = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
3078 (!authority.is_empty()).then(|| authority.to_ascii_lowercase())
3079}
3080
3081fn parse_allowed_hosts(raw: &str) -> Vec<String> {
3084 raw.split(',')
3085 .flat_map(str::split_whitespace)
3086 .map(|s| s.trim().to_ascii_lowercase())
3087 .filter(|s| !s.is_empty())
3088 .collect()
3089}
3090
3091fn host_is_allowed(host_header: &str, allowed: &[String]) -> bool {
3095 let h = host_header.trim().to_ascii_lowercase();
3096 if h.is_empty() {
3097 return false;
3098 }
3099 let h_noport = if h.starts_with('[') {
3101 h.split(']')
3102 .next()
3103 .map_or(h.as_str(), |b| b)
3104 .trim_start_matches('[')
3105 } else {
3106 h.rsplit_once(':').map_or(h.as_str(), |(host, _)| host)
3107 };
3108 allowed
3109 .iter()
3110 .any(|a| a == &h || a == h_noport || a.trim_matches(|c| c == '[' || c == ']') == h_noport)
3111}
3112
3113fn host_check_exempt(path: &str) -> bool {
3116 matches!(path, "/healthz" | "/readyz" | "/metrics") || path.starts_with("/webhooks/")
3117}
3118
3119fn effective_allowed_hosts() -> Option<Vec<String>> {
3125 let raw = std::env::var("SLOC_ALLOWED_HOSTS").ok()?;
3126 let mut allowed = parse_allowed_hosts(&raw);
3127 if allowed.is_empty() {
3128 return None;
3129 }
3130 if let Some(host) = std::env::var("SLOC_PUBLIC_URL")
3131 .ok()
3132 .as_deref()
3133 .and_then(host_of_url)
3134 && !allowed.contains(&host)
3135 {
3136 allowed.push(host);
3137 }
3138 Some(allowed)
3139}
3140
3141async fn host_allowlist_guard(
3146 State(state): State<AppState>,
3147 req: Request<Body>,
3148 next: Next,
3149) -> Response {
3150 if !state.server_mode || host_check_exempt(req.uri().path()) {
3151 return next.run(req).await;
3152 }
3153 let Some(allowed) = effective_allowed_hosts() else {
3154 return next.run(req).await;
3155 };
3156 let host = req
3157 .headers()
3158 .get(header::HOST)
3159 .and_then(|v| v.to_str().ok())
3160 .unwrap_or("");
3161 if host_is_allowed(host, &allowed) {
3162 return next.run(req).await;
3163 }
3164 audit::record("host_rejected", "denied", &[("host", host)]);
3165 tracing::warn!(
3166 event = "host_rejected",
3167 host,
3168 "Host header not in SLOC_ALLOWED_HOSTS"
3169 );
3170 (
3171 StatusCode::MISDIRECTED_REQUEST,
3172 "421 Misdirected Request — this host is not served here\n",
3173 )
3174 .into_response()
3175}
3176
3177#[allow(clippy::fn_params_excessive_bools)]
3183fn emit_server_mode_warnings(
3184 server_mode: bool,
3185 api_keys_empty: bool,
3186 tls_enabled: bool,
3187 trust_proxy: bool,
3188 trusted_proxy_ips: &[IpAddr],
3189) {
3190 if server_mode && api_keys_empty && allow_unauthenticated_server_mode() {
3191 println!(
3195 "WARNING: SLOC_ALLOW_UNAUTHENTICATED=1 — server mode is running with NO \
3196 authentication. Every web endpoint is publicly reachable. Do NOT use this \
3197 outside a trusted, isolated network."
3198 );
3199 }
3200 if server_mode && !tls_enabled {
3201 println!(
3202 "WARNING: TLS is not configured. Traffic is cleartext. \
3203 Set SLOC_TLS_CERT and SLOC_TLS_KEY for HTTPS, \
3204 or terminate TLS at a reverse proxy (nginx, caddy)."
3205 );
3206 }
3207 if server_mode {
3208 println!(
3209 "CORS: set SLOC_ALLOWED_ORIGINS=https://ci.example.com,https://app.example.com \
3210 to restrict cross-origin access (comma-separated)."
3211 );
3212 }
3213 emit_trust_proxy_note(server_mode, trust_proxy, trusted_proxy_ips);
3214 if std::env::var_os("SLOC_GIT_SSL_NO_VERIFY").is_some() {
3215 println!(
3216 "WARNING: SLOC_GIT_SSL_NO_VERIFY is set — TLS certificate verification is \
3217 DISABLED for all git operations. Remove this variable before production use."
3218 );
3219 }
3220}
3221
3222fn emit_trust_proxy_note(server_mode: bool, trust_proxy: bool, trusted_proxy_ips: &[IpAddr]) {
3224 if trust_proxy {
3225 if trusted_proxy_ips.is_empty() {
3226 println!(
3227 "WARNING: SLOC_TRUST_PROXY=1 but SLOC_TRUSTED_PROXY_IPS is not set. \
3228 X-Forwarded-For will NOT be trusted until you specify the proxy IP(s) via \
3229 SLOC_TRUSTED_PROXY_IPS=192.168.1.1,10.0.0.1 to prevent rate-limit bypass."
3230 );
3231 } else {
3232 println!(
3233 "NOTE: SLOC_TRUST_PROXY=1 — X-Forwarded-For is trusted from proxy IPs: {}",
3234 trusted_proxy_ips
3235 .iter()
3236 .map(std::string::ToString::to_string)
3237 .collect::<Vec<_>>()
3238 .join(", ")
3239 );
3240 }
3241 } else if server_mode {
3242 println!(
3243 "NOTE: SLOC_TRUST_PROXY is not set. If oxide-sloc is behind a reverse proxy \
3244 (nginx, Caddy, Traefik), all LAN clients share one rate-limit bucket (the \
3245 proxy IP). Set SLOC_TRUST_PROXY=1 and SLOC_TRUSTED_PROXY_IPS=<proxy-ip> to \
3246 enable per-client rate limiting via X-Forwarded-For."
3247 );
3248 }
3249}
3250
3251fn load_runtime_security_config(server_mode: bool) -> RuntimeSecurityConfig {
3252 let api_keys: Vec<secrecy::SecretBox<String>> = std::env::var("SLOC_API_KEYS")
3253 .or_else(|_| std::env::var("SLOC_API_KEY"))
3254 .unwrap_or_default()
3255 .split(',')
3256 .map(str::trim)
3257 .filter(|s| !s.is_empty())
3258 .map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
3259 .collect();
3260 let readonly_api_keys: Vec<secrecy::SecretBox<String>> =
3261 std::env::var("SLOC_API_KEYS_READONLY")
3262 .unwrap_or_default()
3263 .split(',')
3264 .map(str::trim)
3265 .filter(|s| !s.is_empty())
3266 .map(|s| secrecy::SecretBox::new(Box::new(s.to_owned())))
3267 .collect();
3268 let tls_cert = std::env::var("SLOC_TLS_CERT").ok();
3269 let tls_key = std::env::var("SLOC_TLS_KEY").ok();
3270 let tls_enabled = tls_cert.is_some() && tls_key.is_some();
3271 let trust_proxy = std::env::var("SLOC_TRUST_PROXY").as_deref() == Ok("1");
3272 let trusted_proxy_ips: Vec<IpAddr> = std::env::var("SLOC_TRUSTED_PROXY_IPS")
3273 .unwrap_or_default()
3274 .split(',')
3275 .filter_map(|s| s.trim().parse::<IpAddr>().ok())
3276 .collect();
3277 emit_server_mode_warnings(
3278 server_mode,
3279 api_keys.is_empty(),
3280 tls_enabled,
3281 trust_proxy,
3282 &trusted_proxy_ips,
3283 );
3284 let auth_lockout_threshold = std::env::var("SLOC_AUTH_LOCKOUT_FAILS")
3285 .ok()
3286 .and_then(|v| v.parse::<u32>().ok())
3287 .unwrap_or_else(|| if hardened_mode() { 3 } else { 10 });
3288 let auth_lockout_secs = std::env::var("SLOC_AUTH_LOCKOUT_SECS")
3289 .ok()
3290 .and_then(|v| v.parse::<u64>().ok())
3291 .unwrap_or(3600);
3292 let default_rpm: usize = if server_mode { 120 } else { 600 };
3296 let rate_limit_rpm = std::env::var("SLOC_RATE_LIMIT")
3297 .ok()
3298 .and_then(|v| v.parse::<usize>().ok())
3299 .unwrap_or(default_rpm);
3300 let rate_limiter = Arc::new(IpRateLimiter::new(
3301 Duration::from_mins(1),
3302 rate_limit_rpm,
3303 auth_lockout_threshold,
3304 Duration::from_secs(auth_lockout_secs),
3305 ));
3306 IpRateLimiter::spawn_pruning_task(Arc::clone(&rate_limiter));
3307 RuntimeSecurityConfig {
3308 api_keys,
3309 readonly_api_keys,
3310 tls_cert,
3311 tls_key,
3312 tls_enabled,
3313 trust_proxy,
3314 trusted_proxy_ips,
3315 rate_limiter,
3316 }
3317}
3318
3319#[allow(clippy::too_many_lines)]
3328fn enforce_network_facing_server_mode(config: &mut AppConfig, bind_address: &str) -> bool {
3336 let mut server_mode = config.web.server_mode;
3337 if !server_mode && bind_is_network_facing(bind_address) {
3338 server_mode = true;
3339 config.web.server_mode = true;
3340 println!(
3341 "NOTE: bind address {bind_address} is network-facing (non-loopback), so \
3342 server-mode protections are being enforced automatically: authentication is \
3343 required, server-side scanning is limited to SLOC_ALLOWED_ROOTS, and \
3344 desktop-only routes are disabled. Pass --server to make this explicit."
3345 );
3346 audit::record(
3347 "server_mode_auto_enabled",
3348 "info",
3349 &[("bind", bind_address)],
3350 );
3351 }
3352 server_mode
3353}
3354
3355async fn bind_with_port_fallback(
3359 preferred: SocketAddr,
3360 bind_address: &str,
3361) -> Result<(tokio::net::TcpListener, SocketAddr)> {
3362 for offset in 0u16..=9 {
3363 let mut candidate = preferred;
3364 candidate.set_port(preferred.port().saturating_add(offset));
3365 if let Ok(l) = tokio::net::TcpListener::bind(candidate).await {
3366 return Ok((l, candidate));
3367 }
3368 }
3369 anyhow::bail!(
3370 "failed to bind local web UI on {} (tried ports {}-{}): all in use",
3371 bind_address,
3372 preferred.port(),
3373 preferred.port().saturating_add(9)
3374 )
3375}
3376
3377async fn serve_https(
3380 cert_path: String,
3381 key_path: String,
3382 listener: tokio::net::TcpListener,
3383 app: Router,
3384 addr: SocketAddr,
3385 server_mode: bool,
3386) -> Result<()> {
3387 let tls_config =
3388 build_tls_config(&cert_path, &key_path).context("failed to load TLS certificate/key")?;
3389 let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
3390
3391 let url = format!("https://{addr}/");
3392 println!("OxideSLOC server running at {url} (TLS)");
3393 if let Some(lan) = wildcard_lan_url(&url) {
3394 println!(" Reachable on the LAN at {lan} (sign in at {lan}auth/login)");
3395 }
3396 if server_mode {
3397 emit_hostname_guidance(addr);
3398 }
3399 println!("Use Ctrl+C to stop.");
3400 serve_tls(listener, app, acceptor, server_mode).await
3401}
3402
3403pub async fn serve(mut config: AppConfig) -> Result<()> {
3404 process_start();
3406 let bind_address = config.web.bind_address.clone();
3407 let server_mode = enforce_network_facing_server_mode(&mut config, &bind_address);
3408 let output_root = resolve_output_root(None);
3409 let registry_path = std::env::var("SLOC_REGISTRY_PATH")
3411 .map_or_else(|_| output_root.join("registry.json"), PathBuf::from);
3412 let mut registry = ScanRegistry::load(®istry_path);
3413 registry.prune_stale();
3414 let _ = registry.save(®istry_path);
3415
3416 let sec = load_runtime_security_config(server_mode);
3417 if refuse_unauthenticated_server(server_mode, !sec.api_keys.is_empty()) {
3421 audit::record(
3422 "server_start_refused",
3423 "denied",
3424 &[(
3425 "reason",
3426 "server mode requires SLOC_API_KEY / SLOC_API_KEYS",
3427 )],
3428 );
3429 anyhow::bail!(
3430 "refusing to start: server mode requires authentication. Set SLOC_API_KEY \
3431 (or SLOC_API_KEYS=<k1,k2>) to a secret before launching. To run an \
3432 unauthenticated server on a trusted, isolated network, explicitly set \
3433 SLOC_ALLOW_UNAUTHENTICATED=1 (not recommended)."
3434 );
3435 }
3436 if server_mode && sec.api_keys.is_empty() {
3437 audit::record("server_start_unauthenticated", "warning", &[]);
3438 }
3439 spawn_upload_staging_cleanup();
3440
3441 let git_clones_dir = resolve_git_clones_dir(&output_root);
3442 let schedules_path = std::env::var("SLOC_SCHEDULES_PATH")
3443 .map_or_else(|_| output_root.join("schedules.json"), PathBuf::from);
3444 let schedules = ScheduleStore::load(&schedules_path);
3445 let scan_profiles_path = std::env::var("SLOC_SCAN_PROFILES_PATH")
3446 .map_or_else(|_| output_root.join("scan_profiles.json"), PathBuf::from);
3447 let scan_profiles = ScanProfileStore::load(&scan_profiles_path);
3448 let confluence_path = std::env::var("SLOC_CONFLUENCE_CONFIG_PATH").map_or_else(
3449 |_| output_root.join("confluence_config.json"),
3450 PathBuf::from,
3451 );
3452 let confluence = confluence::ConfluenceConfigStore::load(&confluence_path);
3453 let watched_dirs_path = std::env::var("SLOC_WATCHED_DIRS_PATH")
3454 .map_or_else(|_| output_root.join("watched_dirs.json"), PathBuf::from);
3455 let watched_dirs = WatchedDirsStore::load(&watched_dirs_path);
3456 let cleanup_policy_path = std::env::var("SLOC_CLEANUP_POLICY_PATH")
3457 .map_or_else(|_| output_root.join("cleanup_policy.json"), PathBuf::from);
3458 let cleanup_policy = CleanupPolicyStore::load(&cleanup_policy_path);
3459
3460 let state = AppState {
3461 base_config: config,
3462 artifacts: Arc::new(Mutex::new(HashMap::new())),
3463 async_runs: Arc::new(Mutex::new(HashMap::new())),
3464 registry: Arc::new(Mutex::new(registry)),
3465 registry_path,
3466 analyze_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_ANALYSES)),
3467 server_mode,
3468 allow_unauthenticated: allow_unauthenticated_server_mode(),
3469 tls_enabled: sec.tls_enabled,
3470 api_keys: Arc::new(sec.api_keys),
3471 readonly_api_keys: Arc::new(sec.readonly_api_keys),
3472 rate_limiter: sec.rate_limiter,
3473 trust_proxy: sec.trust_proxy,
3474 trusted_proxy_ips: sec.trusted_proxy_ips,
3475 git_clones_dir,
3476 schedules: Arc::new(Mutex::new(schedules)),
3477 schedules_path,
3478 scan_profiles: Arc::new(Mutex::new(scan_profiles)),
3479 scan_profiles_path,
3480 sessions: Arc::new(std::sync::Mutex::new(HashMap::new())),
3481 confluence: Arc::new(Mutex::new(confluence)),
3482 confluence_path,
3483 watched_dirs: Arc::new(Mutex::new(watched_dirs)),
3484 watched_dirs_path,
3485 cleanup_policy: Arc::new(Mutex::new(cleanup_policy)),
3486 cleanup_policy_path,
3487 cleanup_task_handle: Arc::new(Mutex::new(None)),
3488 };
3489
3490 restart_poll_schedules(&state).await;
3491 warn_insecure_gitlab_webhooks(&state).await;
3492
3493 {
3495 let enabled = state
3496 .cleanup_policy
3497 .lock()
3498 .await
3499 .policy
3500 .as_ref()
3501 .is_some_and(|p| p.enabled);
3502 if enabled {
3503 let handle = spawn_cleanup_policy_task(state.clone());
3504 *state.cleanup_task_handle.lock().await = Some(handle);
3505 }
3506 }
3507
3508 if server_mode {
3511 spawn_disk_guard(state.clone());
3512 }
3513
3514 let app = build_router(state.clone());
3515
3516 let preferred: SocketAddr = bind_address
3517 .parse()
3518 .with_context(|| format!("invalid bind address: {bind_address}"))?;
3519
3520 if require_tls() && !preferred.ip().is_loopback() && !sec.tls_enabled {
3525 audit::record(
3526 "server_start_refused",
3527 "denied",
3528 &[("reason", "TLS required for non-loopback bind")],
3529 );
3530 anyhow::bail!(
3531 "refusing to start: TLS is required for a network-facing bind ({preferred}) but \
3532 SLOC_TLS_CERT / SLOC_TLS_KEY are not set. Provide a certificate and key, bind to \
3533 a loopback address, or unset SLOC_REQUIRE_TLS / SLOC_HARDENED."
3534 );
3535 }
3536
3537 let (listener, addr) = bind_with_port_fallback(preferred, &bind_address).await?;
3538 if addr != preferred {
3539 eprintln!(
3540 "NOTE: port {} is blocked by a system socket (Windows zombie); \
3541 using {} instead.",
3542 preferred.port(),
3543 addr.port()
3544 );
3545 }
3546
3547 if sec.tls_enabled {
3548 let cert_path = sec
3549 .tls_cert
3550 .expect("tls_enabled guarantees SLOC_TLS_CERT is Some");
3551 let key_path = sec
3552 .tls_key
3553 .expect("tls_enabled guarantees SLOC_TLS_KEY is Some");
3554 return serve_https(cert_path, key_path, listener, app, addr, server_mode).await;
3555 }
3556
3557 let url = format!("http://{addr}/");
3558 log_startup_url(&url, server_mode);
3559 if server_mode {
3560 emit_hostname_guidance(addr);
3561 }
3562
3563 axum::serve(
3564 listener,
3565 app.into_make_service_with_connect_info::<SocketAddr>(),
3566 )
3567 .with_graceful_shutdown(shutdown_signal(server_mode))
3568 .await
3569 .context("web server terminated unexpectedly")
3570}
3571
3572fn primary_lan_ip() -> Option<String> {
3576 let socket = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
3577 socket.connect("8.8.8.8:80").ok()?;
3578 let addr = socket.local_addr().ok()?;
3579 let ip = addr.ip();
3580 if ip.is_loopback() {
3581 return None;
3582 }
3583 Some(ip.to_string())
3584}
3585
3586fn wildcard_lan_url(url: &str) -> Option<String> {
3591 if url.contains("0.0.0.0") {
3592 primary_lan_ip().map(|ip| url.replacen("0.0.0.0", &ip, 1))
3593 } else if url.contains("[::]") {
3594 primary_lan_ip().map(|ip| url.replacen("[::]", &ip, 1))
3595 } else {
3596 None
3597 }
3598}
3599
3600fn log_startup_url(url: &str, server_mode: bool) {
3602 if server_mode {
3603 println!("OxideSLOC server running at {url}");
3604 if let Some(lan) = wildcard_lan_url(url) {
3605 println!(" Reachable on the LAN at {lan} (sign in at {lan}auth/login)");
3606 }
3607 println!("Use Ctrl+C to stop.");
3608 } else {
3609 println!("OxideSLOC local web UI running at {url}");
3610 println!("Press Ctrl+C to stop the server.");
3611 let open_url = url.to_owned();
3612 tokio::task::spawn_blocking(move || open_browser_tab(&open_url));
3613 }
3614}
3615
3616fn split_host_port(authority: &str) -> (&str, Option<&str>) {
3620 if let Some(rest) = authority.strip_prefix('[') {
3621 if let Some((host, tail)) = rest.split_once(']') {
3623 let port = tail.strip_prefix(':').filter(|p| !p.is_empty());
3624 return (host, port);
3625 }
3626 }
3627 match authority.rsplit_once(':') {
3628 Some((h, p)) if !p.is_empty() => (h, Some(p)),
3629 _ => (authority, None),
3630 }
3631}
3632
3633fn emit_hostname_guidance(bound: SocketAddr) {
3640 let Ok(public_url) = std::env::var("SLOC_PUBLIC_URL") else {
3641 return;
3642 };
3643 let public_url = public_url.trim();
3644 if public_url.is_empty() {
3645 return;
3646 }
3647 let Some(authority) = host_of_url(public_url) else {
3648 println!("WARNING: SLOC_PUBLIC_URL='{public_url}' has no parseable host; ignoring it.");
3649 return;
3650 };
3651 let (host, _) = split_host_port(&authority);
3652 println!("Canonical URL: {public_url}");
3653
3654 let resolved: Vec<IpAddr> = format!("{host}:{}", bound.port())
3656 .to_socket_addrs()
3657 .map(|it| it.map(|sa| sa.ip()).collect())
3658 .unwrap_or_default();
3659 let local_ips: Vec<IpAddr> = primary_lan_ip()
3660 .and_then(|s| s.parse().ok())
3661 .into_iter()
3662 .chain([
3663 IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
3664 IpAddr::V6(std::net::Ipv6Addr::LOCALHOST),
3665 ])
3666 .chain((!bound.ip().is_unspecified()).then_some(bound.ip()))
3667 .collect();
3668
3669 if resolved.is_empty() {
3670 let ip_hint = primary_lan_ip().unwrap_or_else(|| "<this-host-IP>".to_string());
3671 println!(
3672 " NOTE: '{host}' does not resolve yet. To let LAN clients use the name, either:\n \
3673 • add a DNS A record: {host} -> {ip_hint} (on your DNS server), or\n \
3674 • add a hosts-file line on each client: {ip_hint} {host}\n \
3675 (Windows: C:\\Windows\\System32\\drivers\\etc\\hosts, Linux/macOS: /etc/hosts)"
3676 );
3677 } else if resolved.iter().any(|ip| local_ips.contains(ip)) {
3678 println!(" '{host}' resolves to this host — LAN clients can use the canonical URL.");
3679 } else {
3680 let resolved_list = resolved
3681 .iter()
3682 .map(std::string::ToString::to_string)
3683 .collect::<Vec<_>>()
3684 .join(", ");
3685 println!(
3686 " WARNING: '{host}' resolves to {resolved_list}, which is not an address on this \
3687 host. Point the DNS record / hosts entry at this machine or clients will reach the \
3688 wrong server."
3689 );
3690 }
3691
3692 if effective_allowed_hosts().is_none() {
3693 println!(
3694 " TIP: set SLOC_ALLOWED_HOSTS={host} to reject requests with any other Host header \
3695 (blocks DNS-rebinding / Host-header injection)."
3696 );
3697 }
3698}
3699
3700fn open_browser_tab(url: &str) {
3702 #[cfg(target_os = "windows")]
3707 let _ = std::process::Command::new("rundll32")
3708 .args(["url.dll,FileProtocolHandler", url])
3709 .stdout(Stdio::null())
3710 .stderr(Stdio::null())
3711 .spawn();
3712 #[cfg(target_os = "macos")]
3713 let _ = std::process::Command::new("open")
3714 .arg(url)
3715 .stdout(Stdio::null())
3716 .stderr(Stdio::null())
3717 .spawn();
3718 #[cfg(target_os = "linux")]
3719 let _ = std::process::Command::new("xdg-open")
3720 .arg(url)
3721 .stdout(Stdio::null())
3722 .stderr(Stdio::null())
3723 .spawn();
3724}
3725
3726async fn shutdown_signal(server_mode: bool) {
3728 if tokio::signal::ctrl_c().await.is_ok() {
3729 println!();
3730 if server_mode {
3731 println!("Shutting down OxideSLOC server...");
3732 } else {
3733 println!("Shutting down OxideSLOC local web UI...");
3734 }
3735 println!("Server stopped cleanly.");
3736 }
3737}
3738
3739fn build_tls_config(cert_path: &str, key_path: &str) -> Result<rustls::ServerConfig> {
3741 use rustls_pki_types::pem::PemObject;
3742 use rustls_pki_types::{CertificateDer, PrivateKeyDer};
3743
3744 let cert_bytes =
3745 fs::read(cert_path).with_context(|| format!("failed to read TLS cert: {cert_path}"))?;
3746 let key_bytes =
3747 fs::read(key_path).with_context(|| format!("failed to read TLS key: {key_path}"))?;
3748
3749 let cert_chain: Vec<CertificateDer<'static>> =
3750 CertificateDer::pem_slice_iter(cert_bytes.as_slice())
3751 .collect::<std::result::Result<_, _>>()
3752 .context("failed to parse TLS certificates")?;
3753
3754 let key = PrivateKeyDer::from_pem_slice(key_bytes.as_slice())
3755 .context("failed to parse TLS private key")?;
3756
3757 let builder = rustls::ServerConfig::builder_with_protocol_versions(&[
3762 &rustls::version::TLS13,
3763 &rustls::version::TLS12,
3764 ]);
3765
3766 let config = match client_cert_verifier()? {
3771 Some(verifier) => builder
3772 .with_client_cert_verifier(verifier)
3773 .with_single_cert(cert_chain, key),
3774 None => builder
3775 .with_no_client_auth()
3776 .with_single_cert(cert_chain, key),
3777 };
3778 config.context("failed to build TLS server config")
3779}
3780
3781fn client_cert_verifier() -> Result<Option<Arc<dyn rustls::server::danger::ClientCertVerifier>>> {
3784 use rustls_pki_types::CertificateDer;
3785 use rustls_pki_types::pem::PemObject;
3786
3787 let Some(ca_path) = std::env::var("SLOC_TLS_CLIENT_CA")
3788 .ok()
3789 .filter(|s| !s.is_empty())
3790 else {
3791 return Ok(None);
3792 };
3793 let ca_bytes = fs::read(&ca_path)
3794 .with_context(|| format!("failed to read client CA bundle: {ca_path}"))?;
3795 let mut roots = rustls::RootCertStore::empty();
3796 for cert in CertificateDer::pem_slice_iter(ca_bytes.as_slice()) {
3797 let cert = cert.context("failed to parse client CA certificate")?;
3798 roots
3799 .add(cert)
3800 .context("failed to add client CA certificate to root store")?;
3801 }
3802 let verifier = rustls::server::WebPkiClientVerifier::builder(Arc::new(roots))
3803 .build()
3804 .context("failed to build client certificate verifier")?;
3805 Ok(Some(verifier))
3806}
3807
3808async fn serve_tls(
3810 listener: tokio::net::TcpListener,
3811 app: Router,
3812 acceptor: tokio_rustls::TlsAcceptor,
3813 server_mode: bool,
3814) -> Result<()> {
3815 use hyper_util::rt::{TokioExecutor, TokioIo};
3816 use hyper_util::server::conn::auto::Builder as ConnBuilder;
3817 use hyper_util::service::TowerToHyperService;
3818 use tower::{Service, ServiceExt};
3819
3820 let make_svc = app.into_make_service_with_connect_info::<SocketAddr>();
3821
3822 loop {
3823 tokio::select! {
3824 biased;
3825 _ = tokio::signal::ctrl_c() => {
3826 println!();
3827 if server_mode {
3828 println!("Shutting down OxideSLOC server...");
3829 } else {
3830 println!("Shutting down OxideSLOC local web UI...");
3831 }
3832 println!("Server stopped cleanly.");
3833 return Ok(());
3834 }
3835 result = listener.accept() => {
3836 let (tcp, peer_addr) = result.context("TLS accept failed")?;
3837 let acceptor = acceptor.clone();
3838 let mut factory = make_svc.clone();
3839
3840 tokio::spawn(async move {
3841 let tls = match acceptor.accept(tcp).await {
3842 Ok(s) => s,
3843 Err(e) => {
3844 eprintln!("[sloc-web] TLS handshake from {peer_addr}: {e}");
3845 return;
3846 }
3847 };
3848 let svc = match ServiceExt::<SocketAddr>::ready(&mut factory).await {
3849 Ok(f) => match Service::call(f, peer_addr).await {
3850 Ok(s) => s,
3851 Err(_) => return,
3852 },
3853 Err(_) => return,
3854 };
3855 let io = TokioIo::new(tls);
3856 if let Err(e) = ConnBuilder::new(TokioExecutor::new())
3857 .serve_connection(io, TowerToHyperService::new(svc))
3858 .await
3859 {
3860 eprintln!("[sloc-web] connection error from {peer_addr}: {e}");
3861 }
3862 });
3863 }
3864 }
3865 }
3866}
3867
3868fn build_cors_layer(server_mode: bool) -> CorsLayer {
3871 if server_mode {
3872 let allowed: Vec<axum::http::HeaderValue> = std::env::var("SLOC_ALLOWED_ORIGINS")
3873 .unwrap_or_default()
3874 .split(',')
3875 .filter(|s| !s.is_empty())
3876 .filter_map(|s| s.trim().parse().ok())
3877 .collect();
3878 if allowed.is_empty() {
3879 return CorsLayer::new();
3880 }
3881 CorsLayer::new()
3882 .allow_origin(AllowOrigin::list(allowed))
3883 .allow_methods(AllowMethods::list([
3884 axum::http::Method::GET,
3885 axum::http::Method::POST,
3886 ]))
3887 .allow_headers(AllowHeaders::list([
3888 axum::http::header::AUTHORIZATION,
3889 axum::http::header::CONTENT_TYPE,
3890 ]))
3891 } else {
3892 CorsLayer::new().allow_origin(AllowOrigin::predicate(|origin, _| {
3893 let s = origin.to_str().unwrap_or("");
3894 s.starts_with("http://127.0.0.1:") || s.starts_with("http://localhost:")
3895 }))
3896 }
3897}
3898
3899async fn add_security_headers(
3900 State(state): State<AppState>,
3901 mut req: Request<Body>,
3902 next: Next,
3903) -> Response {
3904 let nonce = uuid::Uuid::new_v4().to_string().replace('-', "");
3905 req.extensions_mut().insert(CspNonce(nonce.clone()));
3906 let mut resp = next.run(req).await;
3907 inject_page_fade_into_html(&mut resp, &nonce).await;
3908 let h = resp.headers_mut();
3909 let frame_ancestors = std::env::var("SLOC_FRAME_ANCESTORS")
3917 .ok()
3918 .map(|v| v.trim().to_string())
3919 .filter(|v| !v.is_empty());
3920 if frame_ancestors.is_none() {
3921 h.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
3922 }
3923 let frame_ancestors_directive = frame_ancestors.as_deref().unwrap_or("'none'");
3924 h.insert(
3925 "X-Content-Type-Options",
3926 HeaderValue::from_static("nosniff"),
3927 );
3928 h.insert(
3929 "Referrer-Policy",
3930 HeaderValue::from_static("strict-origin-when-cross-origin"),
3931 );
3932 let csp = format!(
3933 "default-src 'self'; \
3934 base-uri 'self'; \
3935 form-action 'self'; \
3936 style-src 'self' 'nonce-{nonce}'; \
3937 img-src 'self' data: blob:; \
3938 script-src 'self' 'nonce-{nonce}'; \
3939 font-src 'self' data:; \
3940 object-src 'none'; \
3941 frame-ancestors {frame_ancestors_directive}"
3942 );
3943 h.insert(
3944 "Content-Security-Policy",
3945 HeaderValue::from_str(&csp).unwrap_or_else(|_| {
3946 HeaderValue::from_static(
3947 "default-src 'self'; object-src 'none'; frame-ancestors 'none'",
3948 )
3949 }),
3950 );
3951 h.insert(
3952 "X-Permitted-Cross-Domain-Policies",
3953 HeaderValue::from_static("none"),
3954 );
3955 h.insert(
3956 "Permissions-Policy",
3957 HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"),
3958 );
3959 h.insert(
3960 "Cross-Origin-Opener-Policy",
3961 HeaderValue::from_static("same-origin"),
3962 );
3963 h.insert(
3964 "Cross-Origin-Resource-Policy",
3965 HeaderValue::from_static("same-origin"),
3966 );
3967 h.insert(
3970 "Cross-Origin-Embedder-Policy",
3971 HeaderValue::from_static("require-corp"),
3972 );
3973 if state.tls_enabled {
3974 h.insert(
3975 "Strict-Transport-Security",
3976 HeaderValue::from_static("max-age=31536000; includeSubDomains"),
3977 );
3978 }
3979 resp
3980}
3981
3982async fn csrf_protect(req: Request<Body>, next: Next) -> Response {
3996 use axum::http::Method;
3997
3998 let is_state_changing = matches!(
3999 *req.method(),
4000 Method::POST | Method::PUT | Method::PATCH | Method::DELETE
4001 );
4002 let path = req.uri().path();
4003 let has_token_auth = req.headers().contains_key("X-API-Key")
4004 || req
4005 .headers()
4006 .get(header::AUTHORIZATION)
4007 .and_then(|v| v.to_str().ok())
4008 .is_some_and(|v| v.starts_with("Bearer "));
4009
4010 if !is_state_changing || path.starts_with("/webhooks/") || has_token_auth {
4011 return next.run(req).await;
4012 }
4013
4014 let headers = req.headers();
4015 let header_str = |name: &header::HeaderName| {
4016 headers
4017 .get(name)
4018 .and_then(|v| v.to_str().ok())
4019 .map(str::to_owned)
4020 };
4021 let origin = header_str(&header::ORIGIN);
4022 let referer = header_str(&header::REFERER);
4023 let host = header_str(&header::HOST);
4024
4025 let authority_of = |url: &str| -> Option<String> {
4027 url.split_once("://")
4028 .map(|(_, rest)| rest.split('/').next().unwrap_or(rest).to_owned())
4029 };
4030
4031 let source_authority = origin
4032 .as_deref()
4033 .and_then(authority_of)
4034 .or_else(|| referer.as_deref().and_then(authority_of));
4035
4036 match (source_authority, host) {
4037 (None, _) => next.run(req).await,
4039 (Some(src), Some(h)) if src == h => next.run(req).await,
4040 (Some(src), host) => {
4041 tracing::warn!(
4042 event = "csrf_rejected",
4043 path = %path,
4044 origin = %src,
4045 host = ?host,
4046 "Cross-origin state-changing request rejected (CSRF guard)"
4047 );
4048 (
4049 StatusCode::FORBIDDEN,
4050 "403 Forbidden — cross-origin request rejected\n",
4051 )
4052 .into_response()
4053 }
4054 }
4055}
4056
4057fn page_fade_html(nonce: &str) -> String {
4065 const STYLE: &str = r"
4074@keyframes sloc-page-fade-in{from{opacity:0;}to{opacity:1;}}
4075.page,.site-footer{animation:sloc-page-fade-in .3s ease-out;}
4076body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:0;transition:opacity .16s ease-in;animation:none;}
4077@media (prefers-reduced-motion:reduce){.page,.site-footer{animation:none;}body.sloc-leaving .page,body.sloc-leaving .site-footer{opacity:1;transition:none;}}
4078";
4079 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');});})();";
4088 format!("<style nonce=\"{nonce}\">{STYLE}</style><script nonce=\"{nonce}\">{JS}</script>")
4089}
4090
4091fn loading_overlay_block(nonce: &str, aria_label: &str) -> String {
4106 const TPL: &str = r#"<style nonce="__N__">
4107html.sloc-pending body{visibility:hidden;}
4108html.sloc-pending #rpt-loading-overlay{visibility:visible;}
4109#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%);}
4110#rpt-loading-overlay.fade-out{opacity:0;pointer-events:none;}
4111body.dark-theme #rpt-loading-overlay{background:radial-gradient(125% 125% at 50% 0%,#241810 0%,#1a120b 45%,#130c06 100%);}
4112body.pdf-mode #rpt-loading-overlay{display:none!important;}
4113.rpt-bg-blob{position:absolute;border-radius:50%;filter:blur(64px);opacity:.5;pointer-events:none;will-change:transform;}
4114.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;}
4115.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;}
4116@keyframes rpt-drift-a{0%,100%{transform:translate3d(0,0,0) scale(1);}50%{transform:translate3d(9vw,7vw,0) scale(1.18);}}
4117@keyframes rpt-drift-b{0%,100%{transform:translate3d(0,0,0) scale(1.06);}50%{transform:translate3d(-8vw,-6vw,0) scale(.88);}}
4118body.dark-theme .rpt-bg-blob{opacity:.36;}
4119.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;}
4120@keyframes rpt-card-in{from{opacity:0;transform:translateY(14px) scale(.96);}to{opacity:1;transform:none;}}
4121body.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);}
4122.rpt-load-logo{width:54px;height:54px;object-fit:contain;filter:drop-shadow(0 6px 16px rgba(90,48,12,.45));}
4123.rpt-spinner-wrap{position:relative;width:84px;height:84px;}
4124.rpt-spinner-track{position:absolute;inset:0;border-radius:50%;border:5px solid rgba(196,92,16,.12);}
4125.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));}
4126@keyframes rpt-spin{to{transform:rotate(360deg);}}
4127.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;}
4128body.dark-theme .rpt-spinner-track{border-color:rgba(196,92,16,.2);}
4129body.dark-theme .rpt-spinner-pct{color:#e8932f;}
4130.rpt-loading-text{font-size:15px;font-weight:600;letter-spacing:.08em;display:flex;align-items:baseline;gap:2px;}
4131.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;}
4132@keyframes rpt-text-shimmer{to{background-position:-220% center;}}
4133.rpt-dot{display:inline-block;color:#c45c10;-webkit-text-fill-color:#c45c10;animation:rpt-bounce 1.7s ease-in-out infinite;opacity:0;}
4134.rpt-dot:nth-child(2){animation-delay:.28s;}
4135.rpt-dot:nth-child(3){animation-delay:.56s;}
4136@keyframes rpt-bounce{0%,60%,100%{opacity:0;transform:translateY(0);}30%{opacity:1;transform:translateY(-5px);}}
4137.rpt-status{font-size:12.5px;font-weight:600;letter-spacing:.02em;color:var(--muted,#8a7060);min-height:16px;text-align:center;}
4138.rpt-progress{width:100%;height:6px;border-radius:99px;background:rgba(196,92,16,.12);overflow:hidden;}
4139.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;}
4140body.dark-theme .rpt-progress{background:rgba(196,92,16,.2);}
4141@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;}}
4142</style>
4143<noscript><style nonce="__N__">html.sloc-pending body{visibility:visible!important;}#rpt-loading-overlay{display:none!important;}</style></noscript>
4144<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>
4145<div id="rpt-loading-overlay" aria-live="polite" aria-label="__LABEL__">
4146 <div class="rpt-bg-blob rpt-blob-a" aria-hidden="true"></div>
4147 <div class="rpt-bg-blob rpt-blob-b" aria-hidden="true"></div>
4148 <div class="rpt-load-card">
4149 <img src="/images/logo/small-logo.png" alt="oxide-sloc" class="rpt-load-logo" />
4150 <div class="rpt-spinner-wrap">
4151 <div class="rpt-spinner-track"></div>
4152 <div class="rpt-spinner"></div>
4153 <div class="rpt-spinner-pct" id="rpt-pct">0%</div>
4154 </div>
4155 <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>
4156 <div class="rpt-status" id="rpt-status">__LABEL__</div>
4157 <div class="rpt-progress"><div class="rpt-progress-bar" id="rpt-progress-bar"></div></div>
4158 </div>
4159</div>
4160<script nonce="__N__">
4161(function(){
4162 var ov=document.getElementById('rpt-loading-overlay');
4163 var root=document.documentElement;
4164 function reveal(){root.classList.remove('sloc-pending');}
4165 if(!ov){reveal();return;}
4166 var bar=document.getElementById('rpt-progress-bar'),pct=document.getElementById('rpt-pct'),statusEl=document.getElementById('rpt-status');
4167 var msgs=['__LABEL__','Reading baseline scan','Reading current scan','Computing line deltas','Building file matrix','Rendering charts'];
4168 var mi=0,prog=0,done=false,start=Date.now();
4169 // MIN: minimum time the overlay stays up. SETTLE: extra buffer after the page
4170 // reports ready so the final chart paint completes. CHART_CAP: stop waiting on
4171 // charts after this. HARD_CAP: absolute backstop so the overlay can never stick.
4172 var MIN=1200,SETTLE=750,CHART_CAP=12000,HARD_CAP=25000;
4173 function setProg(p){prog=p;if(bar)bar.style.transform='scaleX('+(p/100).toFixed(3)+')';if(pct)pct.textContent=Math.round(p)+'%';}
4174 function nextMsg(){if(statusEl)statusEl.textContent=msgs[mi%msgs.length];mi++;}
4175 setProg(8);
4176 var msgTimer=setInterval(nextMsg,700);
4177 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);
4178 // These pages draw charts into known SVG containers that start empty and are
4179 // filled by JS once layout is available (some only after a ResizeObserver pass
4180 // post-`load`). Treat the page as ready only once every chart container present
4181 // actually has rendered content, so the overlay never lifts on a half-drawn page.
4182 function chartsRendered(){
4183 var sel=['#cmp-tl-svg','#mc-chart'];
4184 for(var i=0;i<sel.length;i++){var el=document.querySelector(sel[i]);if(el&&!el.firstChild)return false;}
4185 return true;
4186 }
4187 function finish(){
4188 if(done)return;done=true;
4189 clearInterval(msgTimer);clearInterval(progTimer);setProg(100);if(statusEl)statusEl.textContent='Done';
4190 // Reveal the fully-rendered page under the still-opaque overlay, let it paint
4191 // for two frames, THEN fade the overlay — so no half-rendered state is shown.
4192 reveal();
4193 requestAnimationFrame(function(){requestAnimationFrame(function(){
4194 setTimeout(function(){ov.classList.add('fade-out');setTimeout(function(){if(ov.parentNode)ov.parentNode.removeChild(ov);},480);},80);
4195 });});
4196 }
4197 // Wait for `load` (resources + first layout), then poll until the charts have
4198 // actually rendered (or the chart cap), then hold for MIN + SETTLE before fading.
4199 function afterLoad(){
4200 var loadAt=Date.now();
4201 (function poll(){
4202 if(done)return;
4203 if(chartsRendered()||Date.now()-loadAt>=CHART_CAP){
4204 setTimeout(finish,Math.max(MIN-(Date.now()-start),0)+SETTLE);
4205 return;
4206 }
4207 requestAnimationFrame(poll);
4208 })();
4209 }
4210 if(document.readyState==='complete')afterLoad();else window.addEventListener('load',afterLoad);
4211 // Absolute safety net: never let the gate/overlay get stuck.
4212 setTimeout(function(){if(!done)finish();},HARD_CAP);
4213})();
4214</script>"#;
4215 TPL.replace("__N__", nonce).replace("__LABEL__", aria_label)
4216}
4217
4218fn sloc_toast_assets(nonce: &str) -> String {
4233 const TPL: &str = r#"<style nonce="__N__">
4234#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;}
4235.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);}
4236.sloc-toast.sloc-toast-in{opacity:1;transform:none;}
4237.sloc-toast.sloc-toast-out{opacity:0;transform:translateY(8px) scale(.97);}
4238.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;}
4239.sloc-toast-success .sloc-toast-ico{background:#2a6846;}
4240.sloc-toast-error .sloc-toast-ico{background:#b23030;}
4241.sloc-toast-info .sloc-toast-ico{background:#c45c10;}
4242.sloc-toast-success{border-color:#bfe0cc;}
4243.sloc-toast-error{border-color:#e6b3b3;}
4244.sloc-toast-msg{flex:1 1 auto;padding-top:1px;word-break:break-word;}
4245.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;}
4246@keyframes sloc-toast-spin{to{transform:rotate(360deg);}}
4247.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;}
4248.sloc-toast-x:hover{opacity:1;}
4249body.dark-theme .sloc-toast{background:#241a12;color:#f0e6dc;border-color:#3a2c20;box-shadow:0 12px 32px rgba(0,0,0,.5);}
4250body.dark-theme .sloc-toast-success{border-color:#2f5a44;}
4251body.dark-theme .sloc-toast-error{border-color:#6e3434;}
4252body.dark-theme .sloc-toast-spin{border-color:rgba(232,147,47,.25);border-top-color:#e8932f;}
4253@media (prefers-reduced-motion:reduce){.sloc-toast{transition:opacity .2s ease;transform:none!important;}}
4254</style>
4255<script nonce="__N__">
4256(function(){
4257 if(window.slocToast)return;
4258 function wrap(){
4259 var w=document.getElementById('sloc-toast-wrap');
4260 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);}
4261 return w;
4262 }
4263 window.slocToast=function(msg,opts){
4264 opts=opts||{};
4265 var type=opts.type||'info';
4266 var loading=type==='loading';
4267 var t=document.createElement('div');
4268 t.className='sloc-toast sloc-toast-'+(loading?'info':type);
4269 t.setAttribute('role',type==='error'?'alert':'status');
4270 var ico=loading
4271 ? '<span class="sloc-toast-spin" aria-hidden="true"></span>'
4272 : '<span class="sloc-toast-ico" aria-hidden="true">'+(type==='success'?'✓':type==='error'?'✕':'i')+'</span>';
4273 t.innerHTML=ico+'<span class="sloc-toast-msg"></span><button type="button" class="sloc-toast-x" aria-label="Dismiss">×</button>';
4274 t.querySelector('.sloc-toast-msg').textContent=String(msg);
4275 wrap().appendChild(t);
4276 requestAnimationFrame(function(){t.classList.add('sloc-toast-in');});
4277 var gone=false,timer=null;
4278 function close(){
4279 if(gone)return;gone=true;if(timer)clearTimeout(timer);
4280 t.classList.remove('sloc-toast-in');t.classList.add('sloc-toast-out');
4281 setTimeout(function(){if(t.parentNode)t.parentNode.removeChild(t);},300);
4282 }
4283 t.querySelector('.sloc-toast-x').addEventListener('click',close);
4284 var ttl=opts.duration!=null?opts.duration:(type==='error'?7000:loading?0:4500);
4285 if(ttl>0)timer=setTimeout(close,ttl);
4286 return {dismiss:close,el:t};
4287 };
4288 window.slocExportPdf=function(o){
4289 o=o||{};
4290 var btn=o.button||null,orig=btn?btn.innerHTML:'',fname=o.filename||'report.pdf';
4291 if(btn&&btn.disabled)return;
4292 if(btn){btn.disabled=true;btn.style.opacity='0.55';btn.style.cursor='not-allowed';btn.textContent='Generating PDF…';}
4293 var load=window.slocToast('Generating PDF… this can take a few seconds.',{type:'loading'});
4294 return fetch('/export/pdf',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({html:o.html,filename:fname})})
4295 .then(function(r){if(!r.ok)throw new Error('server returned '+r.status);return r.blob();})
4296 .then(function(blob){
4297 var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;
4298 document.body.appendChild(a);a.click();document.body.removeChild(a);
4299 setTimeout(function(){URL.revokeObjectURL(a.href);},400);
4300 load.dismiss();
4301 window.slocToast('PDF exported — '+fname+' saved to your local disk.',{type:'success'});
4302 })
4303 .catch(function(e){
4304 load.dismiss();
4305 window.slocToast('PDF export failed: '+e.message+'. A Chromium-based browser (Chrome/Edge/Brave) must be installed on the server.',{type:'error'});
4306 })
4307 .finally(function(){if(btn){btn.disabled=false;btn.style.opacity='';btn.style.cursor='';btn.innerHTML=orig;}});
4308 };
4309})();
4310</script>"#;
4311 TPL.replace("__N__", nonce)
4312}
4313
4314async fn inject_page_fade_into_html(resp: &mut Response, nonce: &str) {
4319 let is_html = resp
4320 .headers()
4321 .get(header::CONTENT_TYPE)
4322 .and_then(|v| v.to_str().ok())
4323 .is_some_and(|v| v.starts_with("text/html"));
4324 if !is_html {
4325 return;
4326 }
4327 let body = std::mem::replace(resp.body_mut(), Body::empty());
4328 let Ok(bytes) = axum::body::to_bytes(body, usize::MAX).await else {
4329 return;
4330 };
4331 let html = match String::from_utf8(bytes.to_vec()) {
4332 Ok(s) => s,
4333 Err(e) => {
4334 *resp.body_mut() = Body::from(e.into_bytes());
4335 return;
4336 }
4337 };
4338 if html.contains("id=\"rpt-loading-overlay\"") {
4339 *resp.body_mut() = Body::from(html);
4340 return;
4341 }
4342 let insert_at = html
4346 .find("<body")
4347 .and_then(|bi| html[bi..].find('>').map(|g| bi + g + 1))
4348 .or_else(|| {
4349 let lower = html.to_ascii_lowercase();
4350 lower
4351 .find("<body")
4352 .and_then(|bi| lower[bi..].find('>').map(|g| bi + g + 1))
4353 });
4354 let new_html = match insert_at {
4355 Some(at) => {
4356 let mut out = String::with_capacity(html.len() + 1024);
4357 out.push_str(&html[..at]);
4358 out.push_str(&page_fade_html(nonce));
4359 out.push_str(&html[at..]);
4360 out
4361 }
4362 None => html,
4363 };
4364 resp.headers_mut().remove(header::CONTENT_LENGTH);
4365 *resp.body_mut() = Body::from(new_html);
4366}
4367
4368async fn rate_limit(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response {
4369 let peer_ip = req
4370 .extensions()
4371 .get::<axum::extract::ConnectInfo<SocketAddr>>()
4372 .map(|c| c.0.ip());
4373
4374 let ip = peer_ip
4378 .and_then(|peer| {
4379 if state.trust_proxy && state.trusted_proxy_ips.contains(&peer) {
4380 req.headers()
4381 .get("X-Forwarded-For")
4382 .and_then(|v| v.to_str().ok())
4383 .and_then(|s| s.split(',').next())
4384 .and_then(|s| s.trim().parse::<IpAddr>().ok())
4385 } else {
4386 None
4387 }
4388 })
4389 .or(peer_ip)
4390 .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
4391
4392 if !state.rate_limiter.is_allowed(ip) {
4393 tracing::warn!(event = "rate_limit_hit", peer_addr = %ip,
4394 path = %req.uri().path(), "Rate limit exceeded");
4395 return (
4396 StatusCode::TOO_MANY_REQUESTS,
4397 [(header::RETRY_AFTER, "60")],
4398 "429 Too Many Requests\n",
4399 )
4400 .into_response();
4401 }
4402 next.run(req).await
4403}
4404
4405async fn splash(
4406 State(state): State<AppState>,
4407 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4408) -> impl IntoResponse {
4409 let lan_ip = if state.server_mode {
4410 primary_lan_ip()
4411 } else {
4412 None
4413 };
4414 let port = state
4415 .base_config
4416 .web
4417 .bind_address
4418 .rsplit(':')
4419 .next()
4420 .and_then(|p| p.parse::<u16>().ok())
4421 .unwrap_or(4317);
4422 let has_api_key = !state.api_keys.is_empty();
4423 let template = SplashTemplate {
4424 csp_nonce,
4425 server_mode: state.server_mode,
4426 lan_ip,
4427 port,
4428 version: env!("CARGO_PKG_VERSION"),
4429 has_api_key,
4430 };
4431 Html(
4432 template
4433 .render()
4434 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
4435 )
4436}
4437
4438async fn index(
4439 State(state): State<AppState>,
4440 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4441 Query(query): Query<IndexQuery>,
4442) -> impl IntoResponse {
4443 let prefill_json = if query.prefilled.as_deref() == Some("1") || query.path.is_some() {
4444 let policy = query
4445 .mixed_line_policy
4446 .unwrap_or_else(|| "code_only".to_string());
4447 let behavior = query
4448 .binary_file_behavior
4449 .unwrap_or_else(|| "skip".to_string());
4450 let cfg = ScanConfig {
4451 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
4452 path: query.path.unwrap_or_default(),
4453 include_globs: query.include_globs.unwrap_or_default(),
4454 exclude_globs: query.exclude_globs.unwrap_or_default(),
4455 submodule_breakdown: query.submodule_breakdown.as_deref() == Some("enabled"),
4456 mixed_line_policy: policy,
4457 python_docstrings_as_comments: query.python_docstrings_as_comments.as_deref()
4458 != Some("off"),
4459 generated_file_detection: query.generated_file_detection.as_deref() != Some("disabled"),
4460 minified_file_detection: query.minified_file_detection.as_deref() != Some("disabled"),
4461 vendor_directory_detection: query.vendor_directory_detection.as_deref()
4462 != Some("disabled"),
4463 include_lockfiles: query.include_lockfiles.as_deref() == Some("enabled"),
4464 binary_file_behavior: behavior,
4465 output_dir: query.output_dir.unwrap_or_default(),
4466 report_title: query.report_title.unwrap_or_default(),
4467 continuation_line_policy: query
4468 .continuation_line_policy
4469 .unwrap_or_else(default_each_physical_line),
4470 blank_in_block_comment_policy: query
4471 .blank_in_block_comment_policy
4472 .unwrap_or_else(default_count_as_comment),
4473 count_compiler_directives: query.count_compiler_directives.as_deref()
4474 != Some("disabled"),
4475 style_analysis_enabled: query.style_analysis_enabled.as_deref() != Some("disabled"),
4476 style_col_threshold: query
4477 .style_col_threshold
4478 .as_deref()
4479 .and_then(|s| s.parse().ok())
4480 .unwrap_or(80),
4481 style_score_threshold: query
4482 .style_score_threshold
4483 .as_deref()
4484 .and_then(|s| s.parse().ok())
4485 .unwrap_or(0),
4486 style_lang_scope: query.style_lang_scope.unwrap_or_else(default_all_scope),
4487 coverage_file: query.coverage_file.unwrap_or_default(),
4488 cocomo_mode: query.cocomo_mode.unwrap_or_else(default_organic),
4489 complexity_alert: query
4490 .complexity_alert
4491 .as_deref()
4492 .and_then(|s| s.parse().ok())
4493 .unwrap_or(0),
4494 exclude_duplicates: query.exclude_duplicates.as_deref() == Some("enabled"),
4495 activity_window: query
4496 .activity_window
4497 .as_deref()
4498 .and_then(|s| s.parse().ok())
4499 .unwrap_or(90),
4500 attribution: query.attribution.as_deref() != Some("disabled"),
4501 };
4502 serde_json::to_string(&cfg).unwrap_or_else(|_| "{}".to_string())
4503 } else {
4504 "{}".to_string()
4505 };
4506
4507 let git_repo = query.git_repo.unwrap_or_default();
4508 let git_ref = query.git_ref.unwrap_or_default();
4509
4510 let git_label = make_git_label(&git_repo, &git_ref);
4511 let git_output_dir = if git_label.is_empty() {
4512 String::new()
4513 } else {
4514 desktop_dir().join(&git_label).display().to_string()
4515 };
4516 let git_label_json = serde_json::to_string(&git_label).unwrap_or_else(|_| "\"\"".to_owned());
4517 let git_output_dir_json =
4518 serde_json::to_string(&git_output_dir).unwrap_or_else(|_| "\"\"".to_owned());
4519
4520 let template = IndexTemplate {
4521 version: env!("CARGO_PKG_VERSION"),
4522 prefill_json,
4523 csp_nonce,
4524 git_repo,
4525 git_ref,
4526 git_label_json,
4527 git_output_dir_json,
4528 server_mode: state.server_mode,
4529 };
4530
4531 Html(
4532 template
4533 .render()
4534 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
4535 )
4536}
4537
4538async fn scan_setup_handler(
4539 State(state): State<AppState>,
4540 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4541) -> impl IntoResponse {
4542 let recent_scans_json = {
4543 let arr: Vec<serde_json::Value> = {
4544 let reg = state.registry.lock().await;
4545 reg.entries
4546 .iter()
4547 .rev()
4548 .take(6)
4549 .map(|e| {
4550 let run_dir = e
4551 .html_path
4552 .as_ref()
4553 .or(e.json_path.as_ref())
4554 .and_then(|p| p.parent().map(PathBuf::from));
4555 let config_val: Option<serde_json::Value> = run_dir
4556 .and_then(|d| find_scan_config_in_dir(&d))
4557 .and_then(|p| fs::read_to_string(&p).ok())
4558 .and_then(|s| serde_json::from_str(&s).ok());
4559 serde_json::json!({
4560 "project_label": e.project_label,
4561 "timestamp": fmt_la_time(e.timestamp_utc),
4562 "path": e.input_roots.first().map(|s| sanitize_path_str(s)).unwrap_or_default(),
4563 "config": config_val,
4564 })
4565 })
4566 .collect()
4567 };
4568 serde_json::to_string(&arr).unwrap_or_else(|_| "[]".to_string())
4569 };
4570
4571 let template = ScanSetupTemplate {
4572 version: env!("CARGO_PKG_VERSION"),
4573 recent_scans_json,
4574 csp_nonce,
4575 };
4576 Html(
4577 template
4578 .render()
4579 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
4580 )
4581}
4582
4583const GIT_SHA: &str = env!("OXIDE_SLOC_GIT_SHA");
4586const BUILD_TIME: &str = env!("OXIDE_SLOC_BUILD_TIME");
4587
4588pub(crate) fn process_start() -> std::time::Instant {
4591 static START: OnceLock<std::time::Instant> = OnceLock::new();
4592 *START.get_or_init(std::time::Instant::now)
4593}
4594
4595fn uptime_seconds() -> u64 {
4596 process_start().elapsed().as_secs()
4597}
4598
4599async fn healthz() -> &'static str {
4603 "ok"
4604}
4605
4606fn dir_writable(dir: &std::path::Path) -> bool {
4609 if dir.as_os_str().is_empty() {
4610 return true;
4611 }
4612 let Ok(dir) = sloc_core::reject_traversal(dir) else {
4613 return false;
4614 };
4615 let _ = std::fs::create_dir_all(&dir);
4616 let probe = dir.join(".oxide-sloc-health-probe");
4617 match std::fs::write(&probe, b"") {
4618 Ok(()) => {
4619 let _ = std::fs::remove_file(&probe);
4620 true
4621 }
4622 Err(_) => false,
4623 }
4624}
4625
4626fn health_checks(state: &AppState) -> Vec<(&'static str, bool)> {
4629 let registry_dir = state
4630 .registry_path
4631 .parent()
4632 .map_or_else(|| std::path::Path::new("."), |p| p);
4633 vec![
4634 ("registry_writable", dir_writable(registry_dir)),
4635 (
4636 "output_dir_writable",
4637 dir_writable(&resolve_output_root(None)),
4638 ),
4639 ]
4640}
4641
4642fn checks_to_json(checks: &[(&'static str, bool)]) -> serde_json::Value {
4643 let map: serde_json::Map<String, serde_json::Value> = checks
4644 .iter()
4645 .map(|(k, v)| ((*k).to_owned(), serde_json::Value::Bool(*v)))
4646 .collect();
4647 serde_json::Value::Object(map)
4648}
4649
4650async fn api_health_handler(State(state): State<AppState>) -> impl IntoResponse {
4654 let checks = health_checks(&state);
4655 let all_ok = checks.iter().all(|(_, ok)| *ok);
4656 axum::Json(serde_json::json!({
4657 "status": if all_ok { "ok" } else { "degraded" },
4658 "name": "oxide-sloc",
4659 "version": env!("CARGO_PKG_VERSION"),
4660 "git_sha": GIT_SHA,
4661 "build_time": BUILD_TIME,
4662 "uptime_seconds": uptime_seconds(),
4663 "checks": checks_to_json(&checks),
4664 }))
4665}
4666
4667async fn readyz(State(state): State<AppState>) -> impl IntoResponse {
4671 let checks = health_checks(&state);
4672 let ready = checks.iter().all(|(_, ok)| *ok);
4673 let code = if ready {
4674 axum::http::StatusCode::OK
4675 } else {
4676 axum::http::StatusCode::SERVICE_UNAVAILABLE
4677 };
4678 (
4679 code,
4680 axum::Json(serde_json::json!({
4681 "status": if ready { "ready" } else { "not_ready" },
4682 "checks": checks_to_json(&checks),
4683 })),
4684 )
4685}
4686
4687async fn api_version_handler() -> impl IntoResponse {
4688 axum::Json(serde_json::json!({
4689 "name": "oxide-sloc",
4690 "version": env!("CARGO_PKG_VERSION"),
4691 "git_sha": GIT_SHA,
4692 "build_time": BUILD_TIME,
4693 }))
4694}
4695
4696fn prom_runs_total() -> &'static prometheus::IntCounter {
4699 static COUNTER: OnceLock<prometheus::IntCounter> = OnceLock::new();
4700 COUNTER.get_or_init(|| {
4701 prometheus::register_int_counter!(
4702 "oxide_sloc_runs_total",
4703 "Total number of completed analysis runs"
4704 )
4705 .expect("failed to register oxide_sloc_runs_total counter")
4706 })
4707}
4708
4709async fn metrics_handler() -> impl IntoResponse {
4710 use prometheus::Encoder as _;
4711 let mut buf = Vec::new();
4712 let encoder = prometheus::TextEncoder::new();
4713 let _ = encoder.encode(&prometheus::gather(), &mut buf);
4714 (
4715 [(
4716 axum::http::header::CONTENT_TYPE,
4717 "text/plain; version=0.0.4; charset=utf-8",
4718 )],
4719 buf,
4720 )
4721}
4722
4723static OPENAPI_YAML: &str = include_str!("../assets/openapi.yaml");
4724
4725async fn openapi_yaml_handler() -> impl IntoResponse {
4726 (
4727 [(axum::http::header::CONTENT_TYPE, "application/yaml")],
4728 OPENAPI_YAML,
4729 )
4730}
4731
4732static LLMS_TXT: &str = include_str!("../assets/ai/llms.txt");
4733static LLMS_FULL_TXT: &str = include_str!("../assets/ai/llms-full.txt");
4734
4735async fn llms_txt_handler() -> impl IntoResponse {
4736 (
4737 [
4738 (
4739 axum::http::header::CONTENT_TYPE,
4740 "text/plain; charset=utf-8",
4741 ),
4742 (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
4743 ],
4744 LLMS_TXT,
4745 )
4746}
4747
4748async fn llms_full_txt_handler() -> impl IntoResponse {
4749 (
4750 [
4751 (
4752 axum::http::header::CONTENT_TYPE,
4753 "text/plain; charset=utf-8",
4754 ),
4755 (axum::http::header::CACHE_CONTROL, "public, max-age=3600"),
4756 ],
4757 LLMS_FULL_TXT,
4758 )
4759}
4760
4761async fn api_docs_handler(
4762 State(state): State<AppState>,
4763 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
4764) -> impl IntoResponse {
4765 let has_api_key = !state.api_keys.is_empty();
4766 Html(
4767 ApiDocsTemplate {
4768 has_api_key,
4769 csp_nonce,
4770 version: env!("CARGO_PKG_VERSION"),
4771 }
4772 .render()
4773 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
4774 )
4775}
4776
4777async fn chart_js_handler() -> impl IntoResponse {
4778 (
4779 [
4780 (
4781 header::CONTENT_TYPE,
4782 "application/javascript; charset=utf-8",
4783 ),
4784 (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
4785 ],
4786 CHART_JS,
4787 )
4788}
4789
4790async fn app_css_handler() -> impl IntoResponse {
4794 (
4795 [
4796 (header::CONTENT_TYPE, "text/css; charset=utf-8"),
4797 (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
4798 ],
4799 APP_CSS,
4800 )
4801}
4802
4803async fn app_js_handler() -> impl IntoResponse {
4807 (
4808 [
4809 (
4810 header::CONTENT_TYPE,
4811 "application/javascript; charset=utf-8",
4812 ),
4813 (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
4814 ],
4815 APP_JS,
4816 )
4817}
4818
4819async fn report_chart_js_handler() -> impl IntoResponse {
4820 (
4821 [
4822 (
4823 header::CONTENT_TYPE,
4824 "application/javascript; charset=utf-8",
4825 ),
4826 (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
4827 ],
4828 REPORT_CHART_JS,
4829 )
4830}
4831
4832#[derive(Debug, Deserialize)]
4833struct AnalyzeForm {
4834 path: String,
4835 git_repo: Option<String>,
4836 git_ref: Option<String>,
4837 mixed_line_policy: Option<MixedLinePolicy>,
4838 python_docstrings_as_comments: Option<String>,
4839 generated_file_detection: Option<String>,
4840 minified_file_detection: Option<String>,
4841 vendor_directory_detection: Option<String>,
4842 include_lockfiles: Option<String>,
4843 binary_file_behavior: Option<BinaryFileBehavior>,
4844 output_dir: Option<String>,
4845 report_title: Option<String>,
4846 report_header_footer: Option<String>,
4847 include_globs: Option<String>,
4848 exclude_globs: Option<String>,
4849 submodule_breakdown: Option<String>,
4850 coverage_file: Option<String>,
4851 continuation_line_policy: Option<ContinuationLinePolicy>,
4852 blank_in_block_comment_policy: Option<BlankInBlockCommentPolicy>,
4853 count_compiler_directives: Option<String>,
4854 style_col_threshold: Option<String>,
4855 style_analysis_enabled: Option<String>,
4856 style_score_threshold: Option<String>,
4857 style_lang_scope: Option<String>,
4858 cocomo_mode: Option<String>,
4860 complexity_alert: Option<String>,
4862 exclude_duplicates: Option<String>,
4864 activity_window: Option<String>,
4866 attribution: Option<String>,
4868}
4869
4870#[allow(clippy::struct_excessive_bools)]
4871#[derive(Debug, Serialize, Deserialize, Clone)]
4872struct ScanConfig {
4873 oxide_sloc_version: String,
4874 path: String,
4875 include_globs: String,
4876 exclude_globs: String,
4877 submodule_breakdown: bool,
4878 mixed_line_policy: String,
4879 python_docstrings_as_comments: bool,
4880 generated_file_detection: bool,
4881 minified_file_detection: bool,
4882 vendor_directory_detection: bool,
4883 include_lockfiles: bool,
4884 binary_file_behavior: String,
4885 output_dir: String,
4886 report_title: String,
4887 #[serde(default = "default_each_physical_line")]
4889 continuation_line_policy: String,
4890 #[serde(default = "default_count_as_comment")]
4891 blank_in_block_comment_policy: String,
4892 #[serde(default = "default_true_bool")]
4893 count_compiler_directives: bool,
4894 #[serde(default = "default_true_bool")]
4895 style_analysis_enabled: bool,
4896 #[serde(default = "default_style_col_threshold")]
4897 style_col_threshold: u16,
4898 #[serde(default)]
4899 style_score_threshold: u8,
4900 #[serde(default = "default_all_scope")]
4901 style_lang_scope: String,
4902 #[serde(default)]
4903 coverage_file: String,
4904 #[serde(default = "default_organic")]
4905 cocomo_mode: String,
4906 #[serde(default)]
4907 complexity_alert: u32,
4908 #[serde(default)]
4909 exclude_duplicates: bool,
4910 #[serde(default = "default_activity_window")]
4912 activity_window: u32,
4913 #[serde(default = "default_true_bool")]
4915 attribution: bool,
4916}
4917
4918const fn default_activity_window() -> u32 {
4919 90
4920}
4921
4922fn default_each_physical_line() -> String {
4923 "each_physical_line".to_string()
4924}
4925fn default_count_as_comment() -> String {
4926 "count_as_comment".to_string()
4927}
4928const fn default_true_bool() -> bool {
4929 true
4930}
4931const fn default_style_col_threshold() -> u16 {
4932 80
4933}
4934fn default_all_scope() -> String {
4935 "all".to_string()
4936}
4937fn default_organic() -> String {
4938 "organic".to_string()
4939}
4940
4941#[derive(Debug, Deserialize, Default)]
4942struct IndexQuery {
4943 path: Option<String>,
4944 include_globs: Option<String>,
4945 exclude_globs: Option<String>,
4946 submodule_breakdown: Option<String>,
4947 mixed_line_policy: Option<String>,
4948 python_docstrings_as_comments: Option<String>,
4949 generated_file_detection: Option<String>,
4950 minified_file_detection: Option<String>,
4951 vendor_directory_detection: Option<String>,
4952 include_lockfiles: Option<String>,
4953 binary_file_behavior: Option<String>,
4954 output_dir: Option<String>,
4955 report_title: Option<String>,
4956 prefilled: Option<String>,
4957 git_repo: Option<String>,
4958 git_ref: Option<String>,
4959 continuation_line_policy: Option<String>,
4961 blank_in_block_comment_policy: Option<String>,
4962 count_compiler_directives: Option<String>,
4963 style_analysis_enabled: Option<String>,
4964 style_col_threshold: Option<String>,
4965 style_score_threshold: Option<String>,
4966 style_lang_scope: Option<String>,
4967 coverage_file: Option<String>,
4968 cocomo_mode: Option<String>,
4969 complexity_alert: Option<String>,
4970 exclude_duplicates: Option<String>,
4971 activity_window: Option<String>,
4972 attribution: Option<String>,
4973}
4974
4975#[derive(Debug, Deserialize)]
4976struct PreviewQuery {
4977 path: Option<String>,
4978 include_globs: Option<String>,
4979 exclude_globs: Option<String>,
4980}
4981
4982#[cfg(feature = "native-dialog")]
4983#[derive(Debug, Deserialize)]
4984struct PickDirectoryQuery {
4985 kind: Option<String>,
4986 current: Option<String>,
4987}
4988
4989#[cfg(not(feature = "native-dialog"))]
4990#[derive(Debug, Deserialize)]
4991struct PickDirectoryQuery {}
4992
4993#[derive(Debug, Deserialize, Default)]
4994struct ArtifactQuery {
4995 download: Option<String>,
4996}
4997
4998#[cfg(feature = "native-dialog")]
4999#[derive(Debug, Serialize)]
5000struct PickDirectoryResponse {
5001 selected_path: Option<String>,
5002 cancelled: bool,
5003 is_git_repo: bool,
5006}
5007
5008#[cfg(feature = "native-dialog")]
5012fn run_directory_dialog(
5013 title: String,
5014 current: Option<String>,
5015 is_coverage: bool,
5016) -> Option<PathBuf> {
5017 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
5020 let fg_tid = win_dialog_focus::attach_to_foreground();
5021 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
5022 win_dialog_focus::flash_dialog_when_ready(title.clone());
5023
5024 let mut dialog = rfd::FileDialog::new().set_title(&title);
5025 if let Some(current) = current.as_deref() {
5026 let resolved = resolve_input_path(current);
5027 let seed = if resolved.is_dir() {
5028 Some(resolved)
5029 } else {
5030 resolved.parent().map(Path::to_path_buf)
5031 };
5032 if let Some(seed_dir) = seed.filter(|p| p.exists()) {
5033 dialog = dialog.set_directory(seed_dir);
5034 }
5035 }
5036 let result = if is_coverage {
5037 dialog
5038 .add_filter(
5039 "Coverage files (LCOV, Cobertura/JaCoCo XML, coverage.py/Istanbul JSON)",
5040 &["info", "lcov", "xml", "json"],
5041 )
5042 .pick_file()
5043 } else {
5044 dialog.pick_folder()
5045 };
5046
5047 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
5048 win_dialog_focus::detach_from_foreground(fg_tid);
5049
5050 result
5051}
5052
5053#[cfg(feature = "native-dialog")]
5054async fn pick_directory_handler(
5055 State(state): State<AppState>,
5056 Query(query): Query<PickDirectoryQuery>,
5057) -> Response {
5058 if state.server_mode {
5059 return StatusCode::NOT_FOUND.into_response();
5060 }
5061 if std::env::var("SLOC_HEADLESS").is_ok() {
5063 return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
5064 .into_response();
5065 }
5066
5067 let is_coverage = query.kind.as_deref() == Some("coverage");
5068 let title = match query.kind.as_deref() {
5069 Some("output") => "Select output directory",
5070 Some("reports") => "Select folder containing saved reports",
5071 Some("coverage") => "Select LCOV coverage file",
5072 _ => "Select project directory",
5073 }
5074 .to_owned();
5075 let current = query.current.clone();
5076
5077 let picked =
5078 tokio::task::spawn_blocking(move || run_directory_dialog(title, current, is_coverage))
5079 .await
5080 .unwrap_or(None);
5081
5082 let is_git_repo = !is_coverage
5084 && picked
5085 .as_ref()
5086 .is_some_and(|p| sloc_git::is_local_repo_path(&display_path(p)));
5087 Json(PickDirectoryResponse {
5088 selected_path: picked.as_ref().map(|p| display_path(p)),
5089 cancelled: picked.is_none(),
5090 is_git_repo,
5091 })
5092 .into_response()
5093}
5094
5095#[cfg(not(feature = "native-dialog"))]
5096async fn pick_directory_handler(
5097 State(_state): State<AppState>,
5098 Query(_query): Query<PickDirectoryQuery>,
5099) -> Response {
5100 Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
5101}
5102
5103#[cfg(feature = "native-dialog")]
5104async fn pick_file_handler(State(state): State<AppState>) -> Response {
5105 if state.server_mode {
5106 return StatusCode::NOT_FOUND.into_response();
5107 }
5108 if std::env::var("SLOC_HEADLESS").is_ok() {
5109 return Json(serde_json::json!({ "selected_path": null, "cancelled": true }))
5110 .into_response();
5111 }
5112 let picked = tokio::task::spawn_blocking(|| {
5113 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
5114 let fg_tid = win_dialog_focus::attach_to_foreground();
5115 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
5116 win_dialog_focus::flash_dialog_when_ready("Select HTML report".to_owned());
5117
5118 let result = rfd::FileDialog::new()
5119 .set_title("Select HTML report")
5120 .add_filter("HTML report", &["html"])
5121 .pick_file();
5122
5123 #[cfg(all(target_os = "windows", feature = "native-dialog"))]
5124 win_dialog_focus::detach_from_foreground(fg_tid);
5125
5126 result
5127 })
5128 .await
5129 .unwrap_or(None);
5130 Json(PickDirectoryResponse {
5131 selected_path: picked.as_ref().map(|p| display_path(p)),
5132 cancelled: picked.is_none(),
5133 is_git_repo: false,
5134 })
5135 .into_response()
5136}
5137
5138#[cfg(not(feature = "native-dialog"))]
5139async fn pick_file_handler(State(_state): State<AppState>) -> Response {
5140 Json(serde_json::json!({ "selected_path": null, "cancelled": true })).into_response()
5141}
5142
5143fn is_upload_tmp_path(path: &Path) -> bool {
5148 let upload_root = std::env::temp_dir().join("oxide-sloc-uploads");
5149 path.starts_with(&upload_root)
5150}
5151
5152fn is_sample_path(path: &Path) -> bool {
5155 let root = workspace_root();
5156 path.starts_with(root.join("tests").join("fixtures")) || path.starts_with(root.join("samples"))
5157}
5158
5159fn upload_base_dir() -> PathBuf {
5161 std::env::temp_dir().join("oxide-sloc-uploads")
5162}
5163
5164fn upload_staging_path(id: &str) -> PathBuf {
5166 upload_base_dir().join(id)
5167}
5168
5169#[allow(clippy::result_large_err)] fn validate_upload_dir_request(body: &UploadDirRequest) -> Result<(), Response> {
5173 const MAX_FILES: usize = 50_000;
5174 if body.files.is_empty() {
5175 return Err((
5176 StatusCode::BAD_REQUEST,
5177 Json(serde_json::json!({"error": "No files received"})),
5178 )
5179 .into_response());
5180 }
5181 if body.files.len() > MAX_FILES {
5182 return Err((
5183 StatusCode::PAYLOAD_TOO_LARGE,
5184 Json(serde_json::json!({"error": "Too many files (limit 50 000)"})),
5185 )
5186 .into_response());
5187 }
5188 Ok(())
5189}
5190
5191fn resolve_or_create_staging(id: Option<&str>) -> (String, PathBuf) {
5194 match id {
5195 Some(id)
5196 if !id.is_empty()
5197 && id.len() <= 36
5198 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') =>
5199 {
5200 (id.to_string(), upload_staging_path(id))
5201 }
5202 _ => {
5203 let new_id = uuid::Uuid::new_v4().to_string();
5204 let staging = upload_staging_path(&new_id);
5205 (new_id, staging)
5206 }
5207 }
5208}
5209
5210#[allow(clippy::result_large_err)]
5215async fn stage_decoded_entry(
5216 entry: &UploadedFile,
5217 staging: &Path,
5218 total_bytes: &mut usize,
5219 project_root: &mut Option<PathBuf>,
5220) -> Result<(), Response> {
5221 const MAX_TOTAL_BYTES: usize = 500 * 1024 * 1024;
5222
5223 let Ok(data) = base64::Engine::decode(
5224 &base64::engine::general_purpose::STANDARD,
5225 entry.content.as_bytes(),
5226 ) else {
5227 return Ok(());
5228 };
5229
5230 *total_bytes += data.len();
5231 if *total_bytes > MAX_TOTAL_BYTES {
5232 return Err((
5233 StatusCode::PAYLOAD_TOO_LARGE,
5234 Json(serde_json::json!({"error": "Upload exceeds the 500 MB limit"})),
5235 )
5236 .into_response());
5237 }
5238
5239 let rel = std::path::Path::new(&entry.path);
5240 if project_root.is_none()
5241 && let Some(first) = rel.components().next()
5242 {
5243 *project_root = Some(staging.join(first.as_os_str()));
5244 }
5245
5246 let dest = staging.join(rel);
5247 if let Some(parent) = dest.parent()
5248 && tokio::fs::create_dir_all(parent).await.is_err()
5249 {
5250 return Err((
5251 StatusCode::INTERNAL_SERVER_ERROR,
5252 Json(serde_json::json!({"error": "Failed to create directory structure"})),
5253 )
5254 .into_response());
5255 }
5256
5257 if tokio::fs::write(&dest, &data).await.is_err() {
5258 return Err((
5259 StatusCode::INTERNAL_SERVER_ERROR,
5260 Json(serde_json::json!({"error": "Failed to write uploaded file"})),
5261 )
5262 .into_response());
5263 }
5264
5265 Ok(())
5266}
5267
5268#[allow(clippy::result_large_err)]
5274async fn write_upload_files(
5275 files: &[UploadedFile],
5276 staging: &Path,
5277 upload_id: &str,
5278) -> Result<(usize, Option<PathBuf>), Response> {
5279 let mut total_bytes: usize = 0;
5280 let mut project_root: Option<PathBuf> = None;
5281
5282 for entry in files {
5283 let rel = std::path::Path::new(&entry.path);
5284 if rel
5285 .components()
5286 .any(|c| matches!(c, std::path::Component::ParentDir))
5287 {
5288 let _ = tokio::fs::remove_dir_all(staging).await;
5290 tracing::warn!(
5291 event = "upload_path_traversal",
5292 upload_id = %upload_id,
5293 path = %entry.path,
5294 "Upload rejected: path traversal component detected"
5295 );
5296 return Err((
5297 StatusCode::BAD_REQUEST,
5298 Json(serde_json::json!({"error": "Upload rejected: path traversal detected"})),
5299 )
5300 .into_response());
5301 }
5302
5303 if let Err(resp) =
5304 stage_decoded_entry(entry, staging, &mut total_bytes, &mut project_root).await
5305 {
5306 let _ = tokio::fs::remove_dir_all(staging).await;
5307 return Err(resp);
5308 }
5309 }
5310
5311 Ok((files.len(), project_root))
5312}
5313
5314fn parse_tarball_size_caps() -> (u64, u64) {
5317 let compressed = std::env::var("SLOC_MAX_TARBALL_MB")
5318 .ok()
5319 .and_then(|v| v.parse().ok())
5320 .unwrap_or(2048_u64)
5321 * 1024
5322 * 1024;
5323 let decompressed = std::env::var("SLOC_MAX_TARBALL_DECOMPRESSED_MB")
5324 .ok()
5325 .and_then(|v| v.parse().ok())
5326 .unwrap_or(10_240_u64)
5327 * 1024
5328 * 1024;
5329 (compressed, decompressed)
5330}
5331
5332fn tarball_http_body_limit_bytes() -> usize {
5336 std::env::var("SLOC_MAX_TARBALL_MB")
5337 .ok()
5338 .and_then(|v| v.parse::<usize>().ok())
5339 .unwrap_or(2048)
5340 .saturating_mul(1024 * 1024)
5341}
5342
5343#[allow(clippy::result_large_err)] async fn stream_body_to_file(
5348 body: axum::body::Body,
5349 dest_path: &Path,
5350 max_bytes: u64,
5351) -> Result<u64, Response> {
5352 use http_body_util::BodyExt as _;
5353 use tokio::io::AsyncWriteExt as _;
5354
5355 let mut file = match tokio::fs::File::create(dest_path).await {
5356 Ok(f) => f,
5357 Err(e) => {
5358 tracing::error!(
5359 event = "upload_io_error",
5360 "failed to create tarball temp file: {e}"
5361 );
5362 return Err((
5363 StatusCode::INTERNAL_SERVER_ERROR,
5364 Json(serde_json::json!({"error": "Upload initialization failed"})),
5365 )
5366 .into_response());
5367 }
5368 };
5369
5370 let mut body = body;
5371 let mut written: u64 = 0;
5372 loop {
5373 match body.frame().await {
5374 None => break,
5375 Some(Err(e)) => {
5376 let _ = tokio::fs::remove_file(dest_path).await;
5377 return Err((
5378 StatusCode::BAD_REQUEST,
5379 Json(serde_json::json!({"error": format!("Stream error: {e}")})),
5380 )
5381 .into_response());
5382 }
5383 Some(Ok(frame)) => {
5384 if let Ok(data) = frame.into_data() {
5385 written += data.len() as u64;
5386 if written > max_bytes {
5387 let _ = tokio::fs::remove_file(dest_path).await;
5388 return Err((
5389 StatusCode::PAYLOAD_TOO_LARGE,
5390 Json(serde_json::json!({"error": "Tarball exceeds the allowed size limit"})),
5391 )
5392 .into_response());
5393 }
5394 if let Err(e) = file.write_all(&data).await {
5395 let _ = tokio::fs::remove_file(dest_path).await;
5396 tracing::error!(event = "upload_io_error", "tarball write error: {e}");
5397 return Err((
5398 StatusCode::INTERNAL_SERVER_ERROR,
5399 Json(serde_json::json!({"error": "Upload write failed"})),
5400 )
5401 .into_response());
5402 }
5403 }
5404 }
5405 }
5406 }
5407 drop(file);
5408 Ok(written)
5409}
5410
5411#[allow(clippy::result_large_err)] async fn extract_tarball_to_staging(
5416 tarball_path: &Path,
5417 staging: &Path,
5418 max_decompressed_bytes: u64,
5419) -> Result<(), Response> {
5420 let staging_clone = staging.to_path_buf();
5421 let tarball_clone = tarball_path.to_path_buf();
5422 let extract_result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
5423 let file = std::fs::File::open(&tarball_clone)?;
5424 let gz = flate2::read::GzDecoder::new(std::io::BufReader::new(file));
5425 let limited = SizeLimitReader {
5426 inner: gz,
5427 remaining: max_decompressed_bytes,
5428 };
5429 let mut archive = tar::Archive::new(limited);
5430 archive.set_overwrite(true);
5431 archive.set_preserve_permissions(false);
5432 std::fs::create_dir_all(&staging_clone)?;
5433 archive.unpack(&staging_clone)?;
5434 Ok(())
5435 })
5436 .await;
5437 let _ = tokio::fs::remove_file(tarball_path).await;
5438
5439 match extract_result {
5440 Ok(Ok(())) => Ok(()),
5441 Ok(Err(e)) => {
5442 let _ = tokio::fs::remove_dir_all(staging).await;
5443 let is_size_limit = e.to_string().contains("decompressed size limit exceeded");
5444 tracing::warn!(
5445 event = "upload_extract_error",
5446 "tarball extraction failed: {e:#}"
5447 );
5448 let (status, msg) = if is_size_limit {
5449 (
5450 StatusCode::PAYLOAD_TOO_LARGE,
5451 "Archive exceeds the decompressed size limit",
5452 )
5453 } else {
5454 (StatusCode::BAD_REQUEST, "Failed to extract archive")
5455 };
5456 Err((status, Json(serde_json::json!({"error": msg}))).into_response())
5457 }
5458 Err(e) => {
5459 let _ = tokio::fs::remove_dir_all(staging).await;
5460 tracing::error!(
5461 event = "upload_extract_panic",
5462 "tarball extraction task panicked: {e}"
5463 );
5464 Err((
5465 StatusCode::INTERNAL_SERVER_ERROR,
5466 Json(serde_json::json!({"error": "Archive extraction failed"})),
5467 )
5468 .into_response())
5469 }
5470 }
5471}
5472
5473async fn find_single_top_dir(staging: &Path) -> Option<PathBuf> {
5477 let mut entries = tokio::fs::read_dir(staging).await.ok()?;
5478 let first = entries.next_entry().await.ok()??;
5479 if !first.path().is_dir() {
5480 return None;
5481 }
5482 if entries.next_entry().await.unwrap_or(None).is_some() {
5483 return None;
5484 }
5485 Some(first.path())
5486}
5487
5488#[derive(Deserialize)]
5495struct UploadDirRequest {
5496 files: Vec<UploadedFile>,
5497 upload_id: Option<String>,
5500}
5501
5502#[derive(Deserialize)]
5503struct UploadedFile {
5504 path: String,
5506 content: String,
5508}
5509
5510async fn upload_directory_handler(
5520 State(state): State<AppState>,
5521 Json(body): Json<UploadDirRequest>,
5522) -> Response {
5523 if !state.server_mode {
5524 return StatusCode::NOT_FOUND.into_response();
5525 }
5526 if let Err(resp) = validate_upload_dir_request(&body) {
5527 return resp;
5528 }
5529 if body.upload_id.is_none()
5533 && let Some(resp) = reject_when_staging_full(&upload_base_dir()).await
5534 {
5535 return resp;
5536 }
5537 let (upload_id, staging) = resolve_or_create_staging(body.upload_id.as_deref());
5540 match write_upload_files(&body.files, &staging, &upload_id).await {
5541 Ok((file_count, project_root)) => {
5542 let scan_root = project_root.unwrap_or_else(|| staging.clone());
5543 Json(serde_json::json!({
5544 "tmp_path": scan_root.to_string_lossy(),
5545 "file_count": file_count,
5546 "upload_id": upload_id.clone()
5547 }))
5548 .into_response()
5549 }
5550 Err(resp) => resp,
5551 }
5552}
5553
5554#[derive(Deserialize)]
5556struct UploadFileRequest {
5557 filename: String,
5559 content: String,
5561}
5562
5563async fn upload_file_handler(
5569 State(state): State<AppState>,
5570 Json(body): Json<UploadFileRequest>,
5571) -> Response {
5572 const MAX_FILE_BYTES: usize = 10 * 1024 * 1024; if !state.server_mode {
5575 return StatusCode::NOT_FOUND.into_response();
5576 }
5577
5578 let Ok(data) = base64::Engine::decode(
5579 &base64::engine::general_purpose::STANDARD,
5580 body.content.as_bytes(),
5581 ) else {
5582 return (
5583 StatusCode::BAD_REQUEST,
5584 Json(serde_json::json!({"error": "Invalid base64 content"})),
5585 )
5586 .into_response();
5587 };
5588
5589 if data.len() > MAX_FILE_BYTES {
5590 return (
5591 StatusCode::PAYLOAD_TOO_LARGE,
5592 Json(serde_json::json!({"error": "File exceeds the 10 MB limit"})),
5593 )
5594 .into_response();
5595 }
5596
5597 let filename = std::path::Path::new(&body.filename)
5599 .file_name()
5600 .map_or_else(|| "upload".to_owned(), |n| n.to_string_lossy().into_owned());
5601
5602 if let Some(resp) = reject_when_staging_full(&upload_base_dir()).await {
5603 return resp;
5604 }
5605
5606 let upload_id = uuid::Uuid::new_v4();
5607 let staging = std::env::temp_dir()
5608 .join("oxide-sloc-uploads")
5609 .join(upload_id.to_string());
5610
5611 if tokio::fs::create_dir_all(&staging).await.is_err() {
5612 return (
5613 StatusCode::INTERNAL_SERVER_ERROR,
5614 Json(serde_json::json!({"error": "Failed to create staging directory"})),
5615 )
5616 .into_response();
5617 }
5618
5619 let dest = staging.join(&filename);
5620 if tokio::fs::write(&dest, &data).await.is_err() {
5621 let _ = tokio::fs::remove_dir_all(&staging).await;
5622 return (
5623 StatusCode::INTERNAL_SERVER_ERROR,
5624 Json(serde_json::json!({"error": "Failed to write uploaded file"})),
5625 )
5626 .into_response();
5627 }
5628
5629 Json(serde_json::json!({
5630 "tmp_path": dest.to_string_lossy(),
5631 "upload_id": upload_id.to_string()
5632 }))
5633 .into_response()
5634}
5635
5636async fn reject_when_staging_full(upload_base: &Path) -> Option<Response> {
5654 let cap = disk_cap_bytes(None)?;
5655 let base = upload_base.to_path_buf();
5656 let used = tokio::task::spawn_blocking(move || dir_size_bytes(&base))
5657 .await
5658 .unwrap_or(0);
5659 if used >= cap {
5660 tracing::warn!(
5661 event = "upload_rejected_disk_cap",
5662 used,
5663 cap,
5664 "upload staging area is at the disk ceiling; rejecting upload"
5665 );
5666 return Some(
5667 (
5668 StatusCode::INSUFFICIENT_STORAGE,
5669 Json(serde_json::json!({
5670 "error": "Server upload storage is full. Retry after old runs are cleaned up."
5671 })),
5672 )
5673 .into_response(),
5674 );
5675 }
5676 None
5677}
5678
5679struct SizeLimitReader<R> {
5682 inner: R,
5683 remaining: u64,
5684}
5685impl<R: std::io::Read> std::io::Read for SizeLimitReader<R> {
5686 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
5687 if self.remaining == 0 {
5688 return Err(std::io::Error::other("decompressed size limit exceeded"));
5689 }
5690 let n = self.inner.read(buf)?;
5691 self.remaining = self.remaining.saturating_sub(n as u64);
5692 Ok(n)
5693 }
5694}
5695
5696async fn upload_tarball_handler(
5697 State(state): State<AppState>,
5698 request: axum::extract::Request,
5699) -> Response {
5700 if !state.server_mode {
5701 return StatusCode::NOT_FOUND.into_response();
5702 }
5703
5704 let upload_id = uuid::Uuid::new_v4().to_string();
5705 let upload_base = upload_base_dir();
5706 let tarball_path = upload_base.join(format!("{upload_id}.tar.gz"));
5707 let staging = upload_staging_path(&upload_id);
5708 let (max_compressed_bytes, max_decompressed_bytes) = parse_tarball_size_caps();
5709
5710 if let Err(e) = tokio::fs::create_dir_all(&upload_base).await {
5711 tracing::error!(
5712 event = "upload_io_error",
5713 "failed to create upload base dir: {e}"
5714 );
5715 return (
5716 StatusCode::INTERNAL_SERVER_ERROR,
5717 Json(serde_json::json!({"error": "Upload initialization failed"})),
5718 )
5719 .into_response();
5720 }
5721
5722 if let Some(resp) = reject_when_staging_full(&upload_base).await {
5727 let _ = tokio::fs::remove_file(&tarball_path).await;
5728 return resp;
5729 }
5730
5731 let compressed_bytes =
5733 match stream_body_to_file(request.into_body(), &tarball_path, max_compressed_bytes).await {
5734 Ok(n) => n,
5735 Err(resp) => return resp,
5736 };
5737
5738 if let Err(resp) =
5740 extract_tarball_to_staging(&tarball_path, &staging, max_decompressed_bytes).await
5741 {
5742 return resp;
5743 }
5744
5745 let scan_root = find_single_top_dir(&staging)
5750 .await
5751 .unwrap_or_else(|| staging.clone());
5752
5753 let original_bytes = tokio::task::spawn_blocking({
5755 let p = scan_root.clone();
5756 move || dir_size_bytes(&p)
5757 })
5758 .await
5759 .unwrap_or(0);
5760
5761 Json(serde_json::json!({
5762 "tmp_path": scan_root.to_string_lossy(),
5763 "upload_id": upload_id,
5764 "compressed_bytes": compressed_bytes,
5765 "original_bytes": original_bytes,
5766 }))
5767 .into_response()
5768}
5769
5770#[derive(Deserialize)]
5771struct LocateReportForm {
5772 file_path: String,
5773 #[serde(default)]
5774 redirect_url: Option<String>,
5775 #[serde(default)]
5776 expected_run_id: Option<String>,
5777}
5778
5779fn locate_report_error(message: impl Into<String>, csp_nonce: &str) -> Response {
5781 let html = ErrorTemplate {
5782 message: message.into(),
5783 last_report_url: Some("/view-reports".to_string()),
5784 last_report_label: Some("View Reports".to_string()),
5785 run_id: None,
5786 error_code: None,
5787 csp_nonce: csp_nonce.to_owned(),
5788 version: env!("CARGO_PKG_VERSION"),
5789 }
5790 .render()
5791 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
5792 Html(html).into_response()
5793}
5794
5795fn registry_entry_from_run(
5797 run: &AnalysisRun,
5798 json_path: PathBuf,
5799 html_path: PathBuf,
5800) -> RegistryEntry {
5801 let project_label = run.input_roots.first().map_or_else(
5802 || "Unknown Project".to_string(),
5803 |r| sanitize_project_label(r),
5804 );
5805 let (scan_os, scan_host, scan_user, scan_ci) = scan_env_fields(run);
5806 RegistryEntry {
5807 run_id: run.tool.run_id.clone(),
5808 timestamp_utc: run.tool.timestamp_utc,
5809 project_label,
5810 input_roots: run.input_roots.clone(),
5811 json_path: Some(json_path),
5812 html_path: Some(html_path),
5813 pdf_path: None,
5814 summary: ScanSummarySnapshot::from(&run.summary_totals),
5815 csv_path: None,
5816 xlsx_path: None,
5817 git_branch: None,
5818 git_commit: None,
5819 git_commit_long: None,
5820 git_author: None,
5821 git_tags: None,
5822 git_nearest_tag: None,
5823 git_commit_date: None,
5824 scan_os,
5825 scan_host,
5826 scan_user,
5827 scan_ci,
5828 }
5829}
5830
5831pub(crate) async fn register_artifacts_in_registry(
5834 state: &AppState,
5835 label: &str,
5836 run: &AnalysisRun,
5837 artifacts: &RunArtifacts,
5838) {
5839 let Some(json_path) = artifacts.json_path.clone() else {
5840 return;
5841 };
5842 let Some(html_path) = artifacts.html_path.clone() else {
5843 return;
5844 };
5845 let mut entry = registry_entry_from_run(run, json_path, html_path);
5846 entry.project_label = label.to_owned();
5847 {
5848 let mut reg = state.registry.lock().await;
5849 reg.add_entry(entry);
5850 let _ = reg.save(&state.registry_path);
5851 }
5852 maybe_auto_export(artifacts).await;
5853}
5854
5855async fn maybe_auto_export(artifacts: &RunArtifacts) {
5860 if !export_auto_enabled() || export_share_dir().is_none() {
5861 return;
5862 }
5863 let output_dir = artifacts.output_dir.clone();
5864 if output_dir.as_os_str().is_empty() || !output_dir.exists() {
5865 return;
5866 }
5867 let _ = export_run_to_share(output_dir).await;
5869}
5870
5871fn is_html_report_file(p: &Path) -> bool {
5872 p.is_file()
5873 && p.extension()
5874 .and_then(|x| x.to_str())
5875 .is_some_and(|x| x.eq_ignore_ascii_case("html"))
5876 && p.file_name()
5877 .and_then(|n| n.to_str())
5878 .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
5879}
5880
5881fn find_html_report_in_dir(dir: &Path) -> Option<PathBuf> {
5882 fs::read_dir(dir)
5883 .ok()?
5884 .flatten()
5885 .map(|e| e.path())
5886 .find(|p| is_html_report_file(p))
5887}
5888
5889fn find_html_report_in_tree(dir: &Path) -> Option<PathBuf> {
5890 if let Some(f) = find_html_report_in_dir(dir) {
5891 return Some(f);
5892 }
5893 if let Ok(rd) = fs::read_dir(dir) {
5894 for entry in rd.flatten() {
5895 let sub = entry.path();
5896 if sub.is_dir()
5897 && let Some(f) = find_html_report_in_dir(&sub)
5898 {
5899 return Some(f);
5900 }
5901 }
5902 }
5903 None
5904}
5905
5906#[allow(clippy::result_large_err)]
5911fn validate_locate_request(
5912 state: &AppState,
5913 file_path: &str,
5914 csp_nonce: &str,
5915) -> Result<(PathBuf, PathBuf), Response> {
5916 let raw = PathBuf::from(file_path);
5917
5918 let html_path = if raw.is_dir() {
5920 let found = find_html_report_in_tree(&raw);
5921 match found {
5922 Some(f) => strip_unc_prefix(fs::canonicalize(&f).unwrap_or(f)),
5923 None => {
5924 return Err(locate_report_error(
5925 "No HTML report file found in the selected folder.\n\nMake sure you selected \
5926 the folder that contains your scan output (result_*.html or report_*.html).",
5927 csp_nonce,
5928 ));
5929 }
5930 }
5931 } else {
5932 let file_ext = raw
5933 .extension()
5934 .and_then(|e| e.to_str())
5935 .unwrap_or("")
5936 .to_ascii_lowercase();
5937 if file_ext != "html" {
5938 return Err(locate_report_error(
5939 "Please select the scan output folder, or an .html report file directly.",
5940 csp_nonce,
5941 ));
5942 }
5943 match fs::canonicalize(&raw) {
5944 Ok(p) => strip_unc_prefix(p),
5945 Err(_) => {
5946 return Err(locate_report_error(
5947 "Report file not found or path is invalid.",
5948 csp_nonce,
5949 ));
5950 }
5951 }
5952 };
5953
5954 if state.server_mode {
5955 let output_root = resolve_output_root(None);
5956 let canonical_root = fs::canonicalize(&output_root).unwrap_or(output_root);
5957 if !html_path.starts_with(&canonical_root) {
5958 return Err(locate_report_error(
5959 "Report file must be within the configured output directory.",
5960 csp_nonce,
5961 ));
5962 }
5963 }
5964 let parent = match html_path.parent() {
5965 Some(p) => p.to_path_buf(),
5966 None => {
5967 return Err(locate_report_error(
5968 "Report file has no parent directory.",
5969 csp_nonce,
5970 ));
5971 }
5972 };
5973 Ok((html_path, parent))
5974}
5975
5976fn locate_handler_err(want_json: bool, msg: String, csp_nonce: &str) -> Response {
5978 if want_json {
5979 (
5980 StatusCode::UNPROCESSABLE_ENTITY,
5981 axum::Json(serde_json::json!({"ok": false, "message": msg})),
5982 )
5983 .into_response()
5984 } else {
5985 locate_report_error(msg, csp_nonce)
5986 }
5987}
5988
5989fn redirect_or_json_ok(want_json: bool, redirect: &str) -> Response {
5991 if want_json {
5992 axum::Json(serde_json::json!({"ok": true, "redirect": redirect})).into_response()
5993 } else {
5994 axum::response::Redirect::to(redirect).into_response()
5995 }
5996}
5997
5998fn find_json_run_by_id(candidates: &[PathBuf], expected: &str) -> Option<(PathBuf, String)> {
6001 for jpath in candidates {
6002 if let Ok(run) = read_json(jpath)
6003 && (expected.is_empty() || run.tool.run_id == expected)
6004 {
6005 return Some((jpath.clone(), run.tool.run_id));
6006 }
6007 }
6008 None
6009}
6010
6011fn resolve_scan_root(html_path: &Path, parent: &Path) -> PathBuf {
6012 html_path
6013 .parent()
6014 .and_then(|p| p.parent())
6015 .map_or_else(|| parent.to_path_buf(), std::path::Path::to_path_buf)
6016}
6017
6018fn gather_json_candidates(scan_root: &Path, parent: &Path) -> Vec<PathBuf> {
6019 let mut hits = collect_result_json_candidates(scan_root);
6020 if hits.is_empty() {
6021 hits = collect_result_json_candidates(parent);
6022 }
6023 hits.sort();
6024 hits
6025}
6026
6027#[allow(clippy::too_many_lines)]
6028async fn locate_report_handler(
6029 State(state): State<AppState>,
6030 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6031 headers: axum::http::HeaderMap,
6032 Form(form): Form<LocateReportForm>,
6033) -> impl IntoResponse {
6034 let want_json = headers
6035 .get(axum::http::header::ACCEPT)
6036 .and_then(|v| v.to_str().ok())
6037 .is_some_and(|v| v.contains("application/json"));
6038
6039 let (html_path, parent) = match validate_locate_request(&state, &form.file_path, &csp_nonce) {
6040 Ok(v) => v,
6041 Err(resp) => {
6042 if want_json {
6043 return locate_handler_err(
6044 true,
6045 "No HTML report file found in the selected folder. \
6046 Make sure you selected the folder that contains your \
6047 scan output (look for the folder with html/, json/, pdf/ subdirs)."
6048 .to_string(),
6049 &csp_nonce,
6050 );
6051 }
6052 return resp;
6053 }
6054 };
6055
6056 let scan_root_owned = resolve_scan_root(&html_path, &parent);
6059 let scan_root: &Path = &scan_root_owned;
6060 let json_candidates = gather_json_candidates(scan_root, &parent);
6061
6062 let expected_run_id = form
6064 .expected_run_id
6065 .as_deref()
6066 .unwrap_or("")
6067 .trim()
6068 .to_string();
6069
6070 let matched_json = find_json_run_by_id(&json_candidates, &expected_run_id);
6071
6072 if matched_json.is_none() && !json_candidates.is_empty() && !expected_run_id.is_empty() {
6074 let actual = json_candidates
6075 .iter()
6076 .find_map(|p| read_json(p).ok().map(|r| r.tool.run_id))
6077 .unwrap_or_else(|| "unknown".to_string());
6078 return locate_handler_err(
6079 want_json,
6080 format!(
6081 "This folder contains a different scan.\n\n\
6082 Expected run ID : {expected_run_id}\n\
6083 Found run ID : {actual}\n\n\
6084 Please select the folder that contains the correct scan output."
6085 ),
6086 &csp_nonce,
6087 );
6088 }
6089
6090 let safe_redirect = form
6091 .redirect_url
6092 .as_deref()
6093 .filter(|u| u.starts_with('/') && !u.starts_with("//"))
6094 .unwrap_or("/view-reports?linked=1")
6095 .to_string();
6096
6097 let mut reg = state.registry.lock().await;
6098
6099 if let Some((json_path, run_id)) = matched_json {
6100 if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
6102 entry.html_path = Some(html_path);
6103 entry.json_path = Some(json_path);
6104 let _ = reg.save(&state.registry_path);
6105 drop(reg);
6106 state.artifacts.lock().await.remove(&run_id);
6108 return redirect_or_json_ok(want_json, &safe_redirect);
6109 }
6110 match read_json(&json_path) {
6112 Ok(run) => {
6113 let entry = registry_entry_from_run(&run, json_path, html_path);
6114 reg.add_entry(entry);
6115 let _ = reg.save(&state.registry_path);
6116 drop(reg);
6117 state.artifacts.lock().await.remove(&run_id);
6118 return redirect_or_json_ok(want_json, &safe_redirect);
6119 }
6120 Err(e) => {
6121 drop(reg);
6122 return locate_handler_err(
6123 want_json,
6124 format!(
6125 "Found the scan folder but could not parse the result JSON.\n\n\
6126 The file may have been saved by an older version of OxideSLOC. \
6127 Re-running the analysis will create a fresh, compatible record.\n\n\
6128 Error: {e}"
6129 ),
6130 &csp_nonce,
6131 );
6132 }
6133 }
6134 }
6135
6136 if let Some(entry) = reg
6138 .entries
6139 .iter_mut()
6140 .find(|e| !expected_run_id.is_empty() && e.run_id == expected_run_id)
6141 {
6142 entry.html_path = Some(html_path.clone());
6143 let _ = reg.save(&state.registry_path);
6144 drop(reg);
6145 state.artifacts.lock().await.remove(&expected_run_id);
6146 return redirect_or_json_ok(want_json, &safe_redirect);
6147 }
6148
6149 drop(reg);
6150 let hint = if state.server_mode {
6151 String::new()
6152 } else {
6153 format!(
6154 "\n\nSearched folder : {}\nHTML found : {}",
6155 scan_root.display(),
6156 html_path.display()
6157 )
6158 };
6159 locate_handler_err(
6160 want_json,
6161 format!(
6162 "Could not link this report.\n\n\
6163 No result_*.json was found in the selected folder. \
6164 Make sure you selected the top-level scan output folder \
6165 (the one that contains html/, json/, pdf/ subfolders).{hint}"
6166 ),
6167 &csp_nonce,
6168 )
6169}
6170
6171fn find_result_json_in_dir(dir: &Path) -> Option<PathBuf> {
6173 fs::read_dir(dir)
6174 .ok()?
6175 .flatten()
6176 .map(|e| e.path())
6177 .find(|p| {
6178 p.is_file()
6179 && p.file_stem()
6180 .and_then(|n| n.to_str())
6181 .is_some_and(|n| n.starts_with("result"))
6182 && p.extension()
6183 .is_some_and(|e| e.eq_ignore_ascii_case("json"))
6184 })
6185}
6186
6187#[derive(Deserialize)]
6188struct LocateReportsDirForm {
6189 folder_path: String,
6190}
6191
6192#[allow(clippy::too_many_lines)] async fn locate_reports_dir_handler(
6194 State(state): State<AppState>,
6195 Form(form): Form<LocateReportsDirForm>,
6196) -> impl IntoResponse {
6197 if state.server_mode {
6198 return StatusCode::NOT_FOUND.into_response();
6199 }
6200 let folder = match fs::canonicalize(PathBuf::from(&form.folder_path)) {
6201 Ok(p) => strip_unc_prefix(p),
6202 Err(_) => {
6203 return axum::response::Redirect::to(
6204 "/view-reports?error=Folder+not+found+or+path+is+invalid.",
6205 )
6206 .into_response();
6207 }
6208 };
6209 if !folder.is_dir() {
6210 return axum::response::Redirect::to(
6211 "/view-reports?error=Selected+path+is+not+a+directory.",
6212 )
6213 .into_response();
6214 }
6215
6216 let candidates = collect_result_json_candidates(&folder);
6217
6218 if candidates.is_empty() {
6219 return axum::response::Redirect::to(
6220 "/view-reports?error=No+result+JSON+files+found+in+the+selected+folder+or+its+subdirectories.",
6221 )
6222 .into_response();
6223 }
6224
6225 let mut linked_count: usize = 0;
6226 let mut reg = state.registry.lock().await;
6227 for json_path in candidates {
6228 let Some(parent) = json_path.parent().map(PathBuf::from) else {
6229 continue;
6230 };
6231 if is_dir_already_registered(®, &parent) {
6232 continue;
6233 }
6234 let Some(entry) = build_registry_entry_from_json(json_path) else {
6235 continue;
6236 };
6237 reg.add_entry(entry);
6238 linked_count += 1;
6239 }
6240 let _ = reg.save(&state.registry_path);
6241 drop(reg);
6242
6243 if linked_count == 0 {
6244 return axum::response::Redirect::to(
6245 "/view-reports?error=No+new+reports+were+loaded.+The+folder+may+already+be+indexed+or+files+could+not+be+parsed.",
6246 )
6247 .into_response();
6248 }
6249 axum::response::Redirect::to(&format!("/view-reports?linked={linked_count}")).into_response()
6250}
6251
6252#[derive(Deserialize)]
6253struct RelocateScanForm {
6254 run_id: String,
6255 folder_path: String,
6256 redirect_url: String,
6257}
6258
6259fn relocate_folder_err(
6262 want_json: bool,
6263 status: StatusCode,
6264 msg: &str,
6265 run_id: &str,
6266 folder_hint: &str,
6267 redirect_url: &str,
6268 csp_nonce: &str,
6269) -> Response {
6270 if want_json {
6271 (
6272 status,
6273 axum::Json(serde_json::json!({"ok": false, "message": msg})),
6274 )
6275 .into_response()
6276 } else {
6277 missing_scan_relocate_response(msg, run_id, folder_hint, redirect_url, false, csp_nonce)
6278 }
6279}
6280
6281#[allow(clippy::too_many_lines)]
6282async fn relocate_scan_handler(
6283 State(state): State<AppState>,
6284 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
6285 headers: axum::http::HeaderMap,
6286 Form(form): Form<RelocateScanForm>,
6287) -> impl IntoResponse {
6288 let want_json = headers
6289 .get(axum::http::header::ACCEPT)
6290 .and_then(|v| v.to_str().ok())
6291 .is_some_and(|v| v.contains("application/json"));
6292 if state.server_mode {
6293 return StatusCode::NOT_FOUND.into_response();
6294 }
6295
6296 let run_id = form.run_id.trim().to_string();
6297 let redirect_url = form.redirect_url.trim().to_string();
6298
6299 let run_exists = {
6300 let reg = state.registry.lock().await;
6301 reg.find_by_run_id(&run_id).is_some()
6302 };
6303 if !run_exists {
6304 if want_json {
6305 return (
6306 StatusCode::NOT_FOUND,
6307 axum::Json(serde_json::json!({
6308 "ok": false,
6309 "message": format!("Run ID '{run_id}' not found in registry.")
6310 })),
6311 )
6312 .into_response();
6313 }
6314 let html = ErrorTemplate {
6315 message: format!("Run ID '{run_id}' not found in registry."),
6316 last_report_url: Some("/compare-scans".to_string()),
6317 last_report_label: Some("Compare Scans".to_string()),
6318 run_id: Some(run_id.clone()),
6319 error_code: Some(404),
6320 csp_nonce: csp_nonce.clone(),
6321 version: env!("CARGO_PKG_VERSION"),
6322 }
6323 .render()
6324 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
6325 return Html(html).into_response();
6326 }
6327
6328 let folder = match fs::canonicalize(PathBuf::from(form.folder_path.trim())) {
6329 Ok(p) => strip_unc_prefix(p),
6330 Err(_) => {
6331 return relocate_folder_err(
6332 want_json,
6333 StatusCode::UNPROCESSABLE_ENTITY,
6334 "Folder not found or path is invalid.",
6335 &run_id,
6336 form.folder_path.trim(),
6337 &redirect_url,
6338 &csp_nonce,
6339 );
6340 }
6341 };
6342 if !folder.is_dir() {
6343 return relocate_folder_err(
6344 want_json,
6345 StatusCode::UNPROCESSABLE_ENTITY,
6346 "Selected path is not a directory.",
6347 &run_id,
6348 &folder.display().to_string(),
6349 &redirect_url,
6350 &csp_nonce,
6351 );
6352 }
6353
6354 let json_candidates = find_result_files_by_ext(&folder, "json");
6355 if json_candidates.is_empty() {
6356 let msg = format!(
6357 "No result JSON files found in the selected folder.\nSearched: {}",
6358 folder.display()
6359 );
6360 return relocate_folder_err(
6361 want_json,
6362 StatusCode::UNPROCESSABLE_ENTITY,
6363 &msg,
6364 &run_id,
6365 &folder.display().to_string(),
6366 &redirect_url,
6367 &csp_nonce,
6368 );
6369 }
6370
6371 let Some(json_path) = find_matching_run_json(&json_candidates, &run_id) else {
6372 let msg = format!(
6373 "No matching scan found in the selected folder.\n\
6374 The JSON files present do not contain run ID: {run_id}\n\
6375 Searched: {}",
6376 folder.display()
6377 );
6378 return relocate_folder_err(
6379 want_json,
6380 StatusCode::UNPROCESSABLE_ENTITY,
6381 &msg,
6382 &run_id,
6383 &folder.display().to_string(),
6384 &redirect_url,
6385 &csp_nonce,
6386 );
6387 };
6388
6389 let html_path = find_result_files_by_ext(&folder, "html").into_iter().next();
6390 let pdf_path = find_result_files_by_ext(&folder, "pdf").into_iter().next();
6391 update_run_file_paths(&state, &run_id, json_path, html_path, pdf_path).await;
6392
6393 let safe_redirect = if redirect_url.starts_with('/') && !redirect_url.starts_with("//") {
6394 redirect_url
6395 } else {
6396 "/compare-scans".to_string()
6397 };
6398 redirect_or_json_ok(want_json, &safe_redirect)
6399}
6400
6401fn find_result_files_by_ext(folder: &std::path::Path, ext: &str) -> Vec<PathBuf> {
6402 let mut out = Vec::new();
6403 collect_scan_files_by_ext(folder, ext, &mut out);
6404 if let Ok(rd) = fs::read_dir(folder) {
6405 for entry in rd.flatten() {
6406 let sub = entry.path();
6407 if sub.is_dir() {
6408 collect_scan_files_by_ext(&sub, ext, &mut out);
6409 }
6410 }
6411 }
6412 out
6413}
6414
6415fn collect_scan_files_by_ext(dir: &std::path::Path, ext: &str, out: &mut Vec<PathBuf>) {
6416 let Ok(rd) = fs::read_dir(dir) else { return };
6417 for entry in rd.flatten() {
6418 let p = entry.path();
6419 if p.is_file()
6420 && p.file_stem()
6421 .and_then(|n| n.to_str())
6422 .is_some_and(|n| n.starts_with("result") || n.starts_with("report"))
6423 && p.extension().is_some_and(|e| e.eq_ignore_ascii_case(ext))
6424 {
6425 out.push(p);
6426 }
6427 }
6428}
6429
6430fn find_matching_run_json(candidates: &[PathBuf], run_id: &str) -> Option<PathBuf> {
6431 candidates
6432 .iter()
6433 .find(|c| read_json(c).ok().is_some_and(|r| r.tool.run_id == run_id))
6434 .cloned()
6435}
6436
6437fn output_folder_hint(json_path: &std::path::Path) -> String {
6442 let Some(direct_parent) = json_path.parent() else {
6443 return String::new();
6444 };
6445 let parent_name = direct_parent
6446 .file_name()
6447 .and_then(|n| n.to_str())
6448 .unwrap_or("");
6449 if matches!(parent_name, "json" | "html" | "pdf" | "excel") {
6450 direct_parent.parent().map_or_else(
6451 || direct_parent.display().to_string(),
6452 |p| p.display().to_string(),
6453 )
6454 } else {
6455 direct_parent.display().to_string()
6456 }
6457}
6458
6459async fn update_run_file_paths(
6460 state: &AppState,
6461 run_id: &str,
6462 json_path: PathBuf,
6463 html_path: Option<PathBuf>,
6464 pdf_path: Option<PathBuf>,
6465) {
6466 {
6467 let mut reg = state.registry.lock().await;
6468 if let Some(entry) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
6469 entry.json_path = Some(json_path.clone());
6470 if let Some(ref hp) = html_path {
6471 entry.html_path = Some(hp.clone());
6472 }
6473 if let Some(ref pp) = pdf_path {
6474 entry.pdf_path = Some(pp.clone());
6475 }
6476 }
6477 let _ = reg.save(&state.registry_path);
6478 }
6479 {
6482 let mut map = state.artifacts.lock().await;
6483 if let Some(arts) = map.get_mut(run_id) {
6484 arts.json_path = Some(json_path);
6485 if let Some(hp) = html_path {
6486 arts.html_path = Some(hp);
6487 }
6488 if let Some(pp) = pdf_path {
6489 arts.pdf_path = Some(pp);
6490 }
6491 }
6492 }
6493}
6494
6495fn missing_scan_relocate_response(
6496 message: &str,
6497 run_id: &str,
6498 folder_hint: &str,
6499 redirect_url: &str,
6500 server_mode: bool,
6501 csp_nonce: &str,
6502) -> axum::response::Response {
6503 let html = RelocateScanTemplate {
6504 message: message.to_string(),
6505 run_id: run_id.to_string(),
6506 folder_hint: folder_hint.to_string(),
6507 redirect_url: redirect_url.to_string(),
6508 server_mode,
6509 csp_nonce: csp_nonce.to_owned(),
6510 version: env!("CARGO_PKG_VERSION"),
6511 }
6512 .render()
6513 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string());
6514 (StatusCode::NOT_FOUND, Html(html)).into_response()
6515}
6516
6517fn find_file_by_ext(dir: &Path, ext: &str) -> Option<PathBuf> {
6521 fs::read_dir(dir)
6522 .ok()?
6523 .flatten()
6524 .map(|e| e.path())
6525 .find(|p| {
6526 p.is_file()
6527 && p.extension()
6528 .and_then(|e| e.to_str())
6529 .is_some_and(|e| e.eq_ignore_ascii_case(ext))
6530 })
6531}
6532
6533fn subdir_result_json_candidates(sub: &std::path::Path) -> Vec<PathBuf> {
6537 let mut out = Vec::new();
6538 if let Some(j) = find_result_json_in_dir(sub) {
6539 out.push(j);
6540 }
6541 let json_sub = sub.join("json");
6542 if json_sub.is_dir()
6543 && let Some(j) = find_result_json_in_dir(&json_sub)
6544 {
6545 out.push(j);
6546 }
6547 out
6548}
6549
6550fn collect_result_json_candidates(folder: &std::path::Path) -> Vec<PathBuf> {
6551 let mut candidates = Vec::new();
6552 if let Some(j) = find_result_json_in_dir(folder) {
6553 candidates.push(j);
6554 }
6555 let Ok(dir_entries) = fs::read_dir(folder) else {
6556 return candidates;
6557 };
6558 for entry in dir_entries.flatten() {
6559 let sub = entry.path();
6560 if sub.is_dir() {
6561 candidates.extend(subdir_result_json_candidates(&sub));
6562 }
6563 }
6564 candidates
6565}
6566
6567fn is_dir_already_registered(reg: &ScanRegistry, parent: &std::path::Path) -> bool {
6568 reg.entries.iter().any(|e| {
6569 let dir_match = e
6570 .json_path
6571 .as_ref()
6572 .and_then(|p| p.parent())
6573 .is_some_and(|p| p == parent)
6574 || e.html_path
6575 .as_ref()
6576 .and_then(|p| p.parent())
6577 .is_some_and(|p| p == parent);
6578 dir_match
6579 && (e.json_path.as_ref().is_some_and(|p| p.exists())
6580 || e.html_path.as_ref().is_some_and(|p| p.exists()))
6581 })
6582}
6583
6584fn build_registry_entry_from_json(json_path: PathBuf) -> Option<RegistryEntry> {
6585 let json_dir = json_path.parent()?.to_path_buf();
6586 let (html_path, pdf_path, csv_path, xlsx_path) =
6589 if json_dir.file_name().and_then(|n| n.to_str()) == Some("json") {
6590 let scan_root = json_dir.parent()?;
6591 let html = find_html_report_in_dir(&scan_root.join("html"))
6592 .or_else(|| find_html_report_in_dir(scan_root));
6593 let pdf = find_file_by_ext(&scan_root.join("pdf"), "pdf");
6594 let csv = find_file_by_ext(&scan_root.join("excel"), "csv");
6595 let xlsx = find_file_by_ext(&scan_root.join("excel"), "xlsx");
6596 (html, pdf, csv, xlsx)
6597 } else {
6598 let html = fs::read_dir(&json_dir).ok().and_then(|rd| {
6599 rd.flatten()
6600 .map(|e| e.path())
6601 .find(|p| p.extension().and_then(|e| e.to_str()) == Some("html"))
6602 });
6603 (html, None, None, None)
6604 };
6605 let run = read_json(&json_path).ok()?;
6606 let project_label = run.input_roots.first().map_or_else(
6607 || "Unknown Project".to_string(),
6608 |r| sanitize_project_label(r),
6609 );
6610 let (scan_os, scan_host, scan_user, scan_ci) = scan_env_fields(&run);
6611 Some(RegistryEntry {
6612 run_id: run.tool.run_id.clone(),
6613 timestamp_utc: run.tool.timestamp_utc,
6614 project_label,
6615 input_roots: run.input_roots.clone(),
6616 json_path: Some(json_path),
6617 html_path,
6618 pdf_path,
6619 csv_path,
6620 xlsx_path,
6621 summary: ScanSummarySnapshot::from(&run.summary_totals),
6622 git_branch: run.git_branch.clone(),
6623 git_commit: run.git_commit_short.clone(),
6624 git_commit_long: run.git_commit_long.clone(),
6625 git_author: run.git_commit_author.clone(),
6626 git_tags: run.git_tags.clone(),
6627 git_nearest_tag: run.git_nearest_tag.clone(),
6628 git_commit_date: run.git_commit_date.clone(),
6629 scan_os,
6630 scan_host,
6631 scan_user,
6632 scan_ci,
6633 })
6634}
6635
6636fn scan_folder_into_registry(folder: &std::path::Path, reg: &mut ScanRegistry) -> usize {
6639 let mut linked = 0usize;
6640 for json_path in collect_result_json_candidates(folder) {
6641 let Some(parent) = json_path.parent().map(PathBuf::from) else {
6642 continue;
6643 };
6644 if is_dir_already_registered(reg, &parent) {
6645 continue;
6646 }
6647 let Some(entry) = build_registry_entry_from_json(json_path) else {
6648 continue;
6649 };
6650 reg.add_entry(entry);
6651 linked += 1;
6652 }
6653 linked
6654}
6655
6656async fn auto_scan_watched_dirs(state: &AppState) {
6658 let dirs: Vec<PathBuf> = {
6659 let wd = state.watched_dirs.lock().await;
6660 wd.dirs.clone()
6661 };
6662 {
6666 let output_root = resolve_output_root(None);
6667 let mut roots: Vec<PathBuf> = dirs.clone();
6668 if let Ok(canon) = fs::canonicalize(&output_root) {
6669 roots.push(strip_unc_prefix(canon));
6670 }
6671 roots.push(output_root);
6672 let mut reg = state.registry.lock().await;
6673 if reg.retain_under_roots(&roots) > 0 {
6674 let _ = reg.save(&state.registry_path);
6675 }
6676 }
6677 if dirs.is_empty() {
6678 return;
6679 }
6680 let mut reg = state.registry.lock().await;
6681 let mut total = 0usize;
6682 for dir in &dirs {
6683 if dir.is_dir() {
6684 total += scan_folder_into_registry(dir, &mut reg);
6685 }
6686 }
6687 if total > 0 {
6688 let _ = reg.save(&state.registry_path);
6689 }
6690}
6691
6692#[derive(Deserialize)]
6695struct WatchedDirForm {
6696 folder_path: String,
6697 #[serde(default = "default_redirect")]
6698 redirect_to: String,
6699}
6700
6701fn default_redirect() -> String {
6702 "/view-reports".to_string()
6703}
6704
6705#[derive(Deserialize)]
6706struct WatchedDirRefreshForm {
6707 #[serde(default = "default_redirect")]
6708 redirect_to: String,
6709}
6710
6711fn safe_redirect(dest: &str) -> &str {
6715 if dest.starts_with('/') { dest } else { "/" }
6716}
6717
6718async fn add_watched_dir_handler(
6721 State(state): State<AppState>,
6722 Form(form): Form<WatchedDirForm>,
6723) -> impl IntoResponse {
6724 if state.server_mode {
6725 return StatusCode::NOT_FOUND.into_response();
6726 }
6727 let folder = if let Ok(p) = fs::canonicalize(PathBuf::from(&form.folder_path)) {
6728 strip_unc_prefix(p)
6729 } else {
6730 let dest = format!(
6731 "{}?error=Folder+not+found+or+path+is+invalid.",
6732 safe_redirect(&form.redirect_to)
6733 );
6734 return axum::response::Redirect::to(&dest).into_response();
6735 };
6736 if !folder.is_dir() {
6737 let dest = format!(
6738 "{}?error=Selected+path+is+not+a+directory.",
6739 safe_redirect(&form.redirect_to)
6740 );
6741 return axum::response::Redirect::to(&dest).into_response();
6742 }
6743
6744 {
6746 let mut wd = state.watched_dirs.lock().await;
6747 wd.add(folder.clone());
6748 let _ = wd.save(&state.watched_dirs_path);
6749 }
6750
6751 let linked = {
6753 let mut reg = state.registry.lock().await;
6754 let n = scan_folder_into_registry(&folder, &mut reg);
6755 if n > 0 {
6756 let _ = reg.save(&state.registry_path);
6757 }
6758 n
6759 };
6760
6761 let dest = if linked > 0 {
6762 format!("{}?linked={linked}", safe_redirect(&form.redirect_to))
6763 } else {
6764 format!(
6765 "{}?error=Folder+added+to+watch+list+but+no+new+reports+were+found.",
6766 safe_redirect(&form.redirect_to)
6767 )
6768 };
6769 axum::response::Redirect::to(&dest).into_response()
6770}
6771
6772async fn remove_watched_dir_handler(
6773 State(state): State<AppState>,
6774 Form(form): Form<WatchedDirForm>,
6775) -> impl IntoResponse {
6776 if state.server_mode {
6777 return StatusCode::NOT_FOUND.into_response();
6778 }
6779 let folder = PathBuf::from(&form.folder_path);
6780 {
6781 let mut wd = state.watched_dirs.lock().await;
6782 wd.remove(&folder);
6783 let _ = wd.save(&state.watched_dirs_path);
6784 }
6785 {
6787 let mut reg = state.registry.lock().await;
6788 if reg.remove_entries_under(&folder) > 0 {
6789 let _ = reg.save(&state.registry_path);
6790 }
6791 }
6792 axum::response::Redirect::to(safe_redirect(&form.redirect_to)).into_response()
6793}
6794
6795async fn refresh_watched_dirs_handler(
6796 State(state): State<AppState>,
6797 Form(form): Form<WatchedDirRefreshForm>,
6798) -> impl IntoResponse {
6799 if state.server_mode {
6800 return StatusCode::NOT_FOUND.into_response();
6801 }
6802 let dirs: Vec<PathBuf> = {
6803 let wd = state.watched_dirs.lock().await;
6804 wd.dirs.clone()
6805 };
6806 let mut total = 0usize;
6807 {
6808 let mut reg = state.registry.lock().await;
6809 reg.prune_stale();
6810 for dir in &dirs {
6811 if dir.is_dir() {
6812 total += scan_folder_into_registry(dir, &mut reg);
6813 }
6814 }
6815 let _ = reg.save(&state.registry_path);
6816 }
6817 let dest = if total > 0 {
6818 format!("{}?linked={total}", safe_redirect(&form.redirect_to))
6819 } else {
6820 safe_redirect(&form.redirect_to).to_owned()
6821 };
6822 axum::response::Redirect::to(&dest).into_response()
6823}
6824
6825#[derive(Debug, Deserialize)]
6826struct OpenPathQuery {
6827 path: Option<String>,
6828}
6829
6830fn find_existing_ancestor(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
6831 let mut ancestor = std::path::Path::new(raw);
6832 loop {
6833 match ancestor.parent() {
6834 Some(p) => {
6835 ancestor = p;
6836 if ancestor.is_dir() {
6837 break;
6838 }
6839 }
6840 None => return Err((StatusCode::BAD_REQUEST, "no existing ancestor found")),
6841 }
6842 }
6843 Ok(ancestor.to_path_buf())
6844}
6845
6846async fn resolve_open_target(raw: &str) -> Result<PathBuf, (StatusCode, &'static str)> {
6847 match tokio::fs::canonicalize(raw).await {
6848 Ok(canonical) if canonical.is_file() => canonical
6849 .parent()
6850 .map_or(Err((StatusCode::BAD_REQUEST, "path has no parent")), |p| {
6851 Ok(p.to_path_buf())
6852 }),
6853 Ok(canonical) if canonical.is_dir() => Ok(canonical),
6854 Ok(_) => Err((StatusCode::BAD_REQUEST, "path is not a file or directory")),
6855 Err(_) => find_existing_ancestor(raw),
6856 }
6857}
6858
6859async fn open_path_handler(
6860 State(state): State<AppState>,
6861 Query(query): Query<OpenPathQuery>,
6862) -> impl IntoResponse {
6863 if state.server_mode {
6864 return Json(serde_json::json!({
6865 "server_mode_disabled": true,
6866 "message": "Opening a path in the file manager is only available in local desktop mode."
6867 }))
6868 .into_response();
6869 }
6870 if std::env::var("SLOC_HEADLESS").is_ok() {
6872 return Json(serde_json::json!({ "opened": false, "headless": true })).into_response();
6873 }
6874 let raw = match query.path.as_deref() {
6875 Some(p) if !p.is_empty() => p,
6876 _ => return (StatusCode::BAD_REQUEST, "missing path").into_response(),
6877 };
6878
6879 let target = match resolve_open_target(raw).await {
6883 Ok(p) => p,
6884 Err((code, msg)) => return (code, msg).into_response(),
6885 };
6886
6887 #[cfg(target_os = "windows")]
6888 win_dialog_focus::open_folder_foreground(target);
6889 #[cfg(target_os = "macos")]
6890 let _ = std::process::Command::new("open")
6891 .arg(&target)
6892 .stdout(Stdio::null())
6893 .stderr(Stdio::null())
6894 .spawn();
6895 #[cfg(target_os = "linux")]
6896 {
6897 let folder_name = target
6898 .file_name()
6899 .and_then(|n| n.to_str())
6900 .map(str::to_owned);
6901 let _ = std::process::Command::new("xdg-open")
6902 .arg(&target)
6903 .stdout(Stdio::null())
6904 .stderr(Stdio::null())
6905 .spawn();
6906 if let Some(name) = folder_name {
6910 std::thread::spawn(move || {
6911 std::thread::sleep(std::time::Duration::from_millis(800));
6912 let _ = std::process::Command::new("wmctrl")
6913 .args(["-a", &name])
6914 .stdout(Stdio::null())
6915 .stderr(Stdio::null())
6916 .spawn();
6917 });
6918 }
6919 }
6920
6921 Json(serde_json::json!({"ok": true})).into_response()
6922}
6923
6924async fn image_handler(AxumPath((folder, file)): AxumPath<(String, String)>) -> impl IntoResponse {
6925 let (content_type, bytes): (&'static str, &'static [u8]) =
6926 match (folder.as_str(), file.as_str()) {
6927 ("logo", "logo-text.png") => ("image/png", IMG_LOGO_TEXT),
6928 ("logo", "small-logo.png") => ("image/png", IMG_LOGO_SMALL),
6929 ("icons", "c.png") => ("image/png", IMG_ICON_C),
6930 ("icons", "cpp.png") => ("image/png", IMG_ICON_CPP),
6931 ("icons", "c-sharp.png") => ("image/png", IMG_ICON_CSHARP),
6932 ("icons", "python.png") => ("image/png", IMG_ICON_PYTHON),
6933 ("icons", "shell.png") => ("image/png", IMG_ICON_SHELL),
6934 ("icons", "powershell.png") => ("image/png", IMG_ICON_POWERSHELL),
6935 ("icons", "java-script.png") => ("image/png", IMG_ICON_JAVASCRIPT),
6936 ("icons", "html-5.png") => ("image/png", IMG_ICON_HTML),
6937 ("icons", "java.png") => ("image/png", IMG_ICON_JAVA),
6938 ("icons", "visual-basic.png") => ("image/png", IMG_ICON_VB),
6939 ("icons", "asm.png") => ("image/png", IMG_ICON_ASSEMBLY),
6940 ("icons", "go.png") => ("image/png", IMG_ICON_GO),
6941 ("icons", "r.png") => ("image/png", IMG_ICON_R),
6942 ("icons", "xml.png") => ("image/png", IMG_ICON_XML),
6943 ("icons", "groovy.png") => ("image/png", IMG_ICON_GROOVY),
6944 ("icons", "docker.png") => ("image/png", IMG_ICON_DOCKERFILE),
6945 ("icons", "makefile.svg") => ("image/svg+xml", IMG_ICON_MAKEFILE),
6946 ("icons", "perl.svg") => ("image/svg+xml", IMG_ICON_PERL),
6947 _ => return StatusCode::NOT_FOUND.into_response(),
6948 };
6949 ([(header::CONTENT_TYPE, content_type)], bytes).into_response()
6950}
6951
6952fn authorize_preview_path(state: &AppState, resolved: &Path) -> Result<(), Html<String>> {
6957 let Ok(canonical) = fs::canonicalize(resolved) else {
6962 if !is_upload_tmp_path(resolved) && !is_sample_path(resolved) {
6963 return Err(Html(
6964 r#"<div class="preview-error">Preview rejected: path could not be resolved to a real directory.</div>"#.to_string()
6965 ));
6966 }
6967 return Ok(());
6968 };
6969 if is_upload_tmp_path(&canonical) || is_sample_path(&canonical) {
6971 return Ok(());
6972 }
6973 let config = &state.base_config;
6974 if config.discovery.allowed_scan_roots.is_empty() {
6975 return Err(Html(
6976 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()
6977 ));
6978 }
6979 let allowed = path_within_allowed_roots(&canonical, &config.discovery.allowed_scan_roots);
6980 if !allowed {
6981 return Err(Html(
6982 r#"<div class="preview-error">Preview rejected: path is not within an allowed scan directory.</div>"#.to_string()
6983 ));
6984 }
6985 Ok(())
6986}
6987
6988async fn preview_handler(
6989 State(state): State<AppState>,
6990 Query(query): Query<PreviewQuery>,
6991) -> impl IntoResponse {
6992 let raw_path = query
6993 .path
6994 .unwrap_or_else(|| "testing/fixtures/basic".to_string());
6995 let resolved = resolve_input_path(&raw_path);
6996
6997 if state.server_mode && is_sample_path(&resolved) && !resolved.exists() {
7001 return Html(
7002 r#"<div class="preview-error">Sample directory not available on this server.
7003 Enter a path to a project directory or upload files using Browse.</div>"#
7004 .to_string(),
7005 );
7006 }
7007
7008 if state.server_mode
7009 && let Err(resp) = authorize_preview_path(&state, &resolved)
7010 {
7011 return resp;
7012 }
7013
7014 let include_patterns = split_patterns(query.include_globs.as_deref());
7015 let exclude_patterns = split_patterns(query.exclude_globs.as_deref());
7016
7017 match build_preview_html(&resolved, &include_patterns, &exclude_patterns) {
7018 Ok(html) => Html(html),
7019 Err(err) => Html(format!(
7020 r#"<div class="preview-error">Preview failed: {}</div>"#,
7021 escape_html(&err.to_string())
7022 )),
7023 }
7024}
7025
7026#[derive(Debug, Deserialize, Default)]
7027struct SuggestCoverageQuery {
7028 path: Option<String>,
7029}
7030
7031#[derive(Serialize)]
7032struct SuggestCoverageResponse {
7033 found: Option<String>,
7034 tool: Option<&'static str>,
7035 hint: Option<&'static str>,
7036}
7037
7038async fn api_suggest_coverage(Query(query): Query<SuggestCoverageQuery>) -> impl IntoResponse {
7039 const CANDIDATES: &[&str] = &[
7040 "coverage/lcov.info",
7042 "lcov.info",
7043 "target/llvm-cov/lcov.info",
7044 "target/coverage/lcov.info",
7045 "target/debug/coverage/lcov.info",
7046 "coverage/coverage.lcov",
7047 "build/coverage/lcov.info",
7048 "reports/lcov.info",
7049 "coverage.xml",
7051 "coverage/coverage.xml",
7052 "target/site/cobertura/coverage.xml",
7053 "build/reports/coverage/coverage.xml",
7054 "target/site/jacoco/jacoco.xml",
7056 "build/reports/jacoco/test/jacocoTestReport.xml",
7057 "build/reports/jacoco/jacocoTestReport.xml",
7058 "build/jacoco/jacoco.xml",
7059 "coverage.json",
7061 "coverage/coverage.json",
7062 ];
7063 let root = resolve_input_path(query.path.as_deref().unwrap_or(""));
7064 let found = CANDIDATES
7065 .iter()
7066 .map(|rel| root.join(rel))
7067 .find(|p| p.is_file())
7068 .map(|p| display_path(&p));
7069
7070 let (tool, hint) = detect_coverage_tool(&root);
7071 Json(SuggestCoverageResponse { found, tool, hint })
7072}
7073
7074#[derive(Debug, Deserialize, Default)]
7075struct AttribEstimateQuery {
7076 path: Option<String>,
7077}
7078
7079#[derive(Serialize)]
7080struct AttribEstimateResponse {
7081 #[serde(flatten)]
7082 estimate: sloc_core::AttributionEstimate,
7083 estimated_label: String,
7085}
7086
7087fn attrib_estimate_unknown() -> AttribEstimateResponse {
7090 AttribEstimateResponse {
7091 estimate: sloc_core::AttributionEstimate {
7092 is_git: false,
7093 blameable_files: 0,
7094 commit_count: 0,
7095 severity: sloc_core::AttributionSeverity::Light,
7096 recommend_attribution: true,
7097 estimated_seconds: 0,
7098 submodule_count: 0,
7099 combined_commit_count: 0,
7100 branch: None,
7101 },
7102 estimated_label: String::new(),
7103 }
7104}
7105
7106fn humanize_duration(secs: u64) -> String {
7108 if secs == 0 {
7109 String::new()
7110 } else if secs < 60 {
7111 format!("~{secs}s")
7112 } else {
7113 format!("~{} min", secs.div_ceil(60))
7114 }
7115}
7116
7117async fn api_attribution_estimate(
7122 State(state): State<AppState>,
7123 Query(query): Query<AttribEstimateQuery>,
7124) -> impl IntoResponse {
7125 let raw_path = query.path.unwrap_or_default();
7126 if raw_path.trim().is_empty() {
7127 return Json(attrib_estimate_unknown());
7128 }
7129 let resolved = resolve_input_path(&raw_path);
7130 if state.server_mode && authorize_preview_path(&state, &resolved).is_err() {
7132 return Json(attrib_estimate_unknown());
7133 }
7134 let estimate =
7136 tokio::task::spawn_blocking(move || sloc_core::estimate_attribution_cost(&resolved)).await;
7137 match estimate {
7138 Ok(est) => {
7139 let estimated_label = humanize_duration(est.estimated_seconds);
7140 Json(AttribEstimateResponse {
7141 estimate: est,
7142 estimated_label,
7143 })
7144 }
7145 Err(_) => Json(attrib_estimate_unknown()),
7146 }
7147}
7148
7149fn detect_coverage_tool(root: &Path) -> (Option<&'static str>, Option<&'static str>) {
7152 if root.join("Cargo.toml").is_file() {
7153 return (
7154 Some("cargo-llvm-cov"),
7155 Some("cargo llvm-cov --lcov --output-path coverage/lcov.info"),
7156 );
7157 }
7158 if root.join("build.gradle").is_file() || root.join("build.gradle.kts").is_file() {
7159 return (Some("jacoco"), Some("./gradlew jacocoTestReport"));
7160 }
7161 if root.join("pom.xml").is_file() {
7162 return (Some("jacoco"), Some("mvn test jacoco:report"));
7163 }
7164 if root.join("pyproject.toml").is_file() || root.join("setup.py").is_file() {
7165 return (Some("pytest-cov"), Some("pytest --cov --cov-report=xml"));
7166 }
7167 (None, None)
7168}
7169
7170pub(crate) fn path_within_allowed_roots(canonical: &Path, allowed_roots: &[PathBuf]) -> bool {
7175 allowed_roots.iter().any(|root| {
7176 fs::canonicalize(root)
7177 .ok()
7178 .is_some_and(|r| canonical.starts_with(&r))
7179 })
7180}
7181
7182fn forbidden_html_response(message: &str, csp_nonce: &str, fallback: &str) -> Response {
7186 let template = ErrorTemplate {
7187 message: message.to_string(),
7188 last_report_url: None,
7189 last_report_label: None,
7190 run_id: None,
7191 error_code: Some(403),
7192 csp_nonce: csp_nonce.to_owned(),
7193 version: env!("CARGO_PKG_VERSION"),
7194 };
7195 (
7196 StatusCode::FORBIDDEN,
7197 Html(template.render().unwrap_or_else(|_| fallback.to_string())),
7198 )
7199 .into_response()
7200}
7201
7202#[allow(clippy::result_large_err)] fn validate_server_scan_path(
7204 config: &sloc_config::AppConfig,
7205 resolved_path: &Path,
7206 csp_nonce: &str,
7207) -> Result<(), Response> {
7208 if config.discovery.allowed_scan_roots.is_empty() {
7209 return Err(forbidden_html_response(
7210 "Scan path rejected: this server has no scan roots configured, so \
7211 scanning server-side paths is disabled. Set the SLOC_ALLOWED_ROOTS \
7212 environment variable (colon-separated absolute paths) — or \
7213 allowed_scan_roots in the config TOML — then restart. Tip: the \
7214 Browse / directory-upload flow works without this; uploaded folders \
7215 are scanned from the server's temp area and bypass this check.",
7216 csp_nonce,
7217 "<pre>Forbidden.</pre>",
7218 ));
7219 }
7220 let Ok(canonical) = fs::canonicalize(resolved_path) else {
7225 tracing::warn!(event = "path_rejected", path = %resolved_path.display(),
7226 "Scan path does not resolve to a real location");
7227 return Err(forbidden_html_response(
7228 "The requested path could not be resolved to a real directory.",
7229 csp_nonce,
7230 "<pre>Forbidden.</pre>",
7231 ));
7232 };
7233 let allowed = path_within_allowed_roots(&canonical, &config.discovery.allowed_scan_roots);
7234 if !allowed {
7235 tracing::warn!(event = "path_rejected", path = %canonical.display(),
7236 "Scan path not in allowed_scan_roots");
7237 return Err(forbidden_html_response(
7238 "The requested path is not within an allowed scan directory.",
7239 csp_nonce,
7240 "<pre>Path not allowed.</pre>",
7241 ));
7242 }
7243 Ok(())
7244}
7245
7246fn apply_output_dir_exclusions(
7248 config: &mut sloc_config::AppConfig,
7249 project_path: &str,
7250 raw_output_dir: &str,
7251) {
7252 let project_root = resolve_input_path(project_path);
7253 let raw_out = raw_output_dir.trim();
7254 let resolved_out = if raw_out.is_empty() {
7255 project_root.join("sloc")
7256 } else if Path::new(raw_out).is_absolute() {
7257 PathBuf::from(raw_out)
7258 } else {
7259 workspace_root().join(raw_out)
7260 };
7261 if let Ok(rel) = resolved_out.strip_prefix(&project_root)
7262 && let Some(first) = rel.iter().next().and_then(|c| c.to_str())
7263 {
7264 let dir = first.to_string();
7265 if !config.discovery.excluded_directories.contains(&dir) {
7266 config.discovery.excluded_directories.push(dir);
7267 }
7268 }
7269 if !config
7270 .discovery
7271 .excluded_directories
7272 .iter()
7273 .any(|d| d == "sloc")
7274 {
7275 config
7276 .discovery
7277 .excluded_directories
7278 .push("sloc".to_string());
7279 }
7280}
7281
7282const fn summary_snapshot_from_run(run: &AnalysisRun) -> ScanSummarySnapshot {
7284 ScanSummarySnapshot {
7285 files_analyzed: run.summary_totals.files_analyzed,
7286 files_skipped: run.summary_totals.files_skipped,
7287 total_physical_lines: run.summary_totals.total_physical_lines,
7288 code_lines: run.summary_totals.code_lines,
7289 comment_lines: run.summary_totals.comment_lines,
7290 blank_lines: run.summary_totals.blank_lines,
7291 functions: run.summary_totals.functions,
7292 classes: run.summary_totals.classes,
7293 variables: run.summary_totals.variables,
7294 imports: run.summary_totals.imports,
7295 test_count: run.summary_totals.test_count,
7296 coverage_lines_found: run.summary_totals.coverage_lines_found,
7297 coverage_lines_hit: run.summary_totals.coverage_lines_hit,
7298 coverage_functions_found: run.summary_totals.coverage_functions_found,
7299 coverage_functions_hit: run.summary_totals.coverage_functions_hit,
7300 coverage_branches_found: run.summary_totals.coverage_branches_found,
7301 coverage_branches_hit: run.summary_totals.coverage_branches_hit,
7302 }
7303}
7304
7305fn scan_env_fields(
7310 run: &AnalysisRun,
7311) -> (
7312 Option<String>,
7313 Option<String>,
7314 Option<String>,
7315 Option<String>,
7316) {
7317 let non_empty = |s: &str| (!s.is_empty()).then(|| s.to_owned());
7318 (
7319 non_empty(&run.environment.operating_system),
7320 non_empty(&run.environment.initiator_hostname),
7321 non_empty(&run.environment.initiator_username),
7322 run.environment.ci_name.clone().filter(|s| !s.is_empty()),
7323 )
7324}
7325
7326pub(crate) fn build_run_registry_entry(
7327 run: &AnalysisRun,
7328 run_id: &str,
7329 project_label: &str,
7330 artifacts: &RunArtifacts,
7331) -> RegistryEntry {
7332 let (scan_os, scan_host, scan_user, scan_ci) = scan_env_fields(run);
7333 RegistryEntry {
7334 run_id: run_id.to_owned(),
7335 timestamp_utc: run.tool.timestamp_utc,
7336 project_label: project_label.to_owned(),
7337 input_roots: run.input_roots.clone(),
7338 json_path: artifacts.json_path.clone(),
7339 html_path: artifacts.html_path.clone(),
7340 pdf_path: artifacts.pdf_path.clone(),
7341 csv_path: artifacts.csv_path.clone(),
7342 xlsx_path: artifacts.xlsx_path.clone(),
7343 summary: summary_snapshot_from_run(run),
7344 git_branch: run.git_branch.clone(),
7345 git_commit: run.git_commit_short.clone(),
7346 git_commit_long: run.git_commit_long.clone(),
7347 git_author: run.git_commit_author.clone(),
7348 git_tags: run.git_tags.clone(),
7349 git_nearest_tag: run.git_nearest_tag.clone(),
7350 git_commit_date: run.git_commit_date.clone(),
7351 scan_os,
7352 scan_host,
7353 scan_user,
7354 scan_ci,
7355 }
7356}
7357
7358fn apply_form_to_config(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
7360 if let Some(policy) = form.mixed_line_policy {
7361 config.analysis.mixed_line_policy = policy;
7362 }
7363 config.analysis.python_docstrings_as_comments = form.python_docstrings_as_comments.is_some();
7364 config.analysis.generated_file_detection =
7365 form.generated_file_detection.as_deref() != Some("disabled");
7366 config.analysis.minified_file_detection =
7367 form.minified_file_detection.as_deref() != Some("disabled");
7368 config.analysis.vendor_directory_detection =
7369 form.vendor_directory_detection.as_deref() != Some("disabled");
7370 config.analysis.include_lockfiles = form.include_lockfiles.as_deref() == Some("enabled");
7371 if let Some(binary_behavior) = form.binary_file_behavior {
7372 config.analysis.binary_file_behavior = binary_behavior;
7373 }
7374 apply_report_opts(config, form);
7375 config.discovery.include_globs = split_patterns(form.include_globs.as_deref());
7376 config.discovery.exclude_globs = split_patterns(form.exclude_globs.as_deref());
7377 config.discovery.submodule_breakdown = form.submodule_breakdown.as_deref() == Some("enabled");
7378 if let Some(policy) = form.continuation_line_policy {
7379 config.analysis.continuation_line_policy = policy;
7380 }
7381 if let Some(policy) = form.blank_in_block_comment_policy {
7382 config.analysis.blank_in_block_comment_policy = policy;
7383 }
7384 config.analysis.count_compiler_directives =
7385 form.count_compiler_directives.as_deref() != Some("disabled");
7386 config.analysis.attribution = form.attribution.as_deref() != Some("disabled");
7387 apply_style_threshold(config, form);
7388 apply_coverage_path(config, form);
7389}
7390
7391fn apply_report_opts(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
7392 if let Some(report_title) = form.report_title.as_deref() {
7393 let trimmed = report_title.trim();
7394 if !trimmed.is_empty() {
7395 config.reporting.report_title = trimmed.to_string();
7396 }
7397 }
7398 if let Some(hf) = form.report_header_footer.as_deref() {
7399 let trimmed = hf.trim();
7400 config.reporting.report_header_footer = if trimmed.is_empty() {
7401 None
7402 } else {
7403 Some(trimmed.to_string())
7404 };
7405 }
7406}
7407
7408fn apply_style_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
7409 apply_style_col_threshold(config, form);
7410 apply_style_analysis_enabled(config, form);
7411 apply_style_score_threshold(config, form);
7412 apply_style_lang_scope(config, form);
7413 apply_activity_window(config, form);
7414}
7415
7416fn apply_style_col_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
7417 if let Some(threshold_str) = form.style_col_threshold.as_deref()
7418 && let Ok(t) = threshold_str.parse::<u16>()
7419 && (t == 80 || t == 100 || t == 120)
7420 {
7421 config.analysis.style_col_threshold = t;
7422 }
7423}
7424
7425fn apply_style_analysis_enabled(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
7426 if let Some(v) = form.style_analysis_enabled.as_deref() {
7427 config.analysis.style_analysis_enabled = v != "disabled";
7428 }
7429}
7430
7431fn apply_style_score_threshold(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
7432 if let Some(v) = form.style_score_threshold.as_deref()
7433 && let Ok(t) = v.parse::<u8>()
7434 {
7435 config.analysis.style_score_threshold = t.min(100);
7436 }
7437}
7438
7439fn apply_style_lang_scope(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
7440 if let Some(v) = form.style_lang_scope.as_deref() {
7441 let scope = v.trim();
7442 if scope == "c_family" || scope == "all" {
7443 config.analysis.style_lang_scope = scope.to_string();
7444 }
7445 }
7446}
7447
7448fn apply_activity_window(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
7449 if let Some(w) = form.activity_window.as_deref() {
7452 let w = w.trim();
7453 if !w.is_empty()
7454 && let Ok(days) = w.parse::<u32>()
7455 {
7456 config.analysis.activity_window_days = Some(days);
7457 }
7458 }
7459}
7460
7461fn apply_coverage_path(config: &mut sloc_config::AppConfig, form: &AnalyzeForm) {
7462 if let Some(cov) = &form.coverage_file {
7463 let trimmed = cov.trim();
7464 if !trimmed.is_empty() {
7465 config.analysis.coverage_file = Some(std::path::PathBuf::from(trimmed));
7466 }
7467 }
7468}
7469
7470async fn finalize_pdf_task(
7477 result: std::result::Result<anyhow::Result<()>, tokio::task::JoinError>,
7478 label: &str,
7479 run_id: String,
7480 artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
7481) {
7482 let failed = match result {
7483 Ok(Ok(())) => false,
7484 Ok(Err(err)) => {
7485 eprintln!("[oxide-sloc][pdf] {label} failed: {err}");
7486 true
7487 }
7488 Err(err) => {
7489 eprintln!("[oxide-sloc][pdf] {label} task panicked: {err}");
7490 true
7491 }
7492 };
7493 if failed {
7494 let mut map = artifacts.lock().await;
7495 if let Some(entry) = map.get_mut(&run_id) {
7496 entry.pdf_path = None;
7497 }
7498 }
7499}
7500
7501fn spawn_pdf_background(
7502 pending_pdf: PendingPdf,
7503 run_id: String,
7504 artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
7505) {
7506 if let Some((pdf_src, pdf_dst, cleanup_src)) = pending_pdf {
7507 tokio::spawn(async move {
7508 let result = tokio::task::spawn_blocking(move || {
7509 let r = write_pdf_from_html(&pdf_src, &pdf_dst);
7510 if cleanup_src {
7511 let _ = fs::remove_file(&pdf_src);
7512 }
7513 r
7514 })
7515 .await;
7516 finalize_pdf_task(result, "background PDF", run_id, artifacts).await;
7517 });
7518 }
7519}
7520
7521fn spawn_native_pdf_background(
7525 json_path: PathBuf,
7526 pdf_dest: PathBuf,
7527 run_id: String,
7528 artifacts: Arc<Mutex<HashMap<String, RunArtifacts>>>,
7529) {
7530 tokio::spawn(async move {
7531 let result = tokio::task::spawn_blocking(move || {
7532 let run = sloc_core::read_json(&json_path)?;
7533 write_pdf_from_run(&run, &pdf_dest)
7534 })
7535 .await;
7536 finalize_pdf_task(result, "on-demand PDF", run_id, artifacts).await;
7537 });
7538}
7539
7540fn sum_added_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
7542 cmp.file_deltas
7543 .iter()
7544 .map(|f| match f.status {
7545 FileChangeStatus::Added => f.current_code,
7546 FileChangeStatus::Modified => f.code_delta.max(0),
7547 _ => 0,
7548 })
7549 .sum()
7550}
7551
7552fn sum_removed_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
7554 cmp.file_deltas
7555 .iter()
7556 .map(|f| match f.status {
7557 FileChangeStatus::Removed => f.baseline_code,
7558 FileChangeStatus::Modified => (-f.code_delta).max(0),
7559 _ => 0,
7560 })
7561 .sum()
7562}
7563
7564fn sum_unmodified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
7566 cmp.file_deltas
7567 .iter()
7568 .filter(|f| f.status == FileChangeStatus::Unchanged)
7569 .map(|f| f.current_code)
7570 .sum()
7571}
7572
7573fn sum_modified_code_lines(cmp: &sloc_core::ScanComparison) -> i64 {
7575 cmp.file_deltas
7576 .iter()
7577 .filter(|f| f.status == FileChangeStatus::Modified)
7578 .map(|f| f.current_code)
7579 .sum()
7580}
7581
7582fn build_submodule_row(
7584 s: &sloc_core::SubmoduleSummary,
7585 run: &AnalysisRun,
7586 run_id: &str,
7587 run_dir: &Path,
7588) -> SubmoduleRow {
7589 let safe = sanitize_project_label(&s.name);
7590 let artifact_key = format!("sub_{safe}");
7591 let pdf_artifact_key = format!("sub_{safe}_pdf");
7592 let html_url = if run.effective_configuration.discovery.submodule_breakdown {
7593 let parent_path = run
7594 .input_roots
7595 .first()
7596 .map_or("", std::string::String::as_str);
7597 let sub_run = build_sub_run(run, s, parent_path);
7598 let pdf_server_url = format!("/runs/{pdf_artifact_key}/{run_id}");
7599 render_sub_report_html(&sub_run, Some(&pdf_server_url))
7600 .ok()
7601 .and_then(|sub_html| {
7602 let sub_dir = sloc_core::reject_traversal(&run_dir.join("submodules")).ok()?;
7603 let _ = fs::create_dir_all(&sub_dir);
7604 let html_path = sub_dir.join(format!("{artifact_key}.html"));
7605 if fs::write(&html_path, sub_html.as_bytes()).is_ok() {
7606 let pdf_path = sub_dir.join(format!("{artifact_key}.pdf"));
7609 let _ = write_pdf_from_run(&sub_run, &pdf_path);
7610 Some(format!("/runs/{artifact_key}/{run_id}"))
7611 } else {
7612 None
7613 }
7614 })
7615 } else {
7616 None
7617 };
7618 SubmoduleRow {
7619 name: s.name.clone(),
7620 relative_path: s.relative_path.clone(),
7621 files_analyzed: s.files_analyzed,
7622 code_lines: s.code_lines,
7623 comment_lines: s.comment_lines,
7624 blank_lines: s.blank_lines,
7625 total_physical_lines: s.total_physical_lines,
7626 html_url,
7627 }
7628}
7629
7630#[allow(clippy::similar_names)]
7633#[allow(clippy::significant_drop_tightening)] #[allow(clippy::too_many_lines)]
7635async fn analyze_handler(
7636 State(state): State<AppState>,
7637 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
7638 Form(form): Form<AnalyzeForm>,
7639) -> impl IntoResponse {
7640 let Ok(sem_permit) = Arc::clone(&state.analyze_semaphore).try_acquire_owned() else {
7641 let template = ErrorTemplate {
7642 message: format!(
7643 "Server is busy — all {MAX_CONCURRENT_ANALYSES} analysis slots are in use. \
7644 Please wait a moment and try again."
7645 ),
7646 last_report_url: None,
7647 last_report_label: None,
7648 run_id: None,
7649 error_code: Some(503),
7650 csp_nonce: csp_nonce.clone(),
7651 version: env!("CARGO_PKG_VERSION"),
7652 };
7653 return (
7654 StatusCode::SERVICE_UNAVAILABLE,
7655 Html(
7656 template
7657 .render()
7658 .unwrap_or_else(|_| "<pre>Server busy.</pre>".to_string()),
7659 ),
7660 )
7661 .into_response();
7662 };
7663
7664 let mut config = state.base_config.clone();
7665
7666 let git_repo = form.git_repo.clone().filter(|s| !s.is_empty());
7667 let git_ref_name = form.git_ref.clone().filter(|s| !s.is_empty());
7668 let is_git_mode = git_repo.is_some() && git_ref_name.is_some();
7669
7670 if !is_git_mode {
7671 let resolved_path = resolve_input_path(&form.path);
7672 if state.server_mode
7673 && !is_upload_tmp_path(&resolved_path)
7674 && !is_sample_path(&resolved_path)
7675 && let Err(resp) = validate_server_scan_path(&config, &resolved_path, &csp_nonce)
7676 {
7677 return resp;
7678 }
7679 config.discovery.root_paths = vec![resolved_path];
7680 }
7681
7682 apply_form_to_config(&mut config, &form);
7683 apply_output_dir_exclusions(
7684 &mut config,
7685 &form.path,
7686 form.output_dir.as_deref().unwrap_or(""),
7687 );
7688
7689 let wait_id = uuid::Uuid::new_v4().to_string();
7691 let wait_id_json = serde_json::to_string(&wait_id).unwrap_or_else(|_| "\"\"".to_owned());
7692
7693 let cancel_token = Arc::new(std::sync::atomic::AtomicBool::new(false));
7695 let task_cancel = Arc::clone(&cancel_token);
7696
7697 let phase = Arc::new(std::sync::Mutex::new("Starting".to_string()));
7699 let task_phase = Arc::clone(&phase);
7700
7701 let files_done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
7702 let files_total = Arc::new(std::sync::atomic::AtomicUsize::new(0));
7703 let task_files_done = Arc::clone(&files_done);
7704 let task_files_total = Arc::clone(&files_total);
7705
7706 let attrib_done = Arc::new(std::sync::atomic::AtomicUsize::new(0));
7710 let attrib_total = Arc::new(std::sync::atomic::AtomicUsize::new(0));
7711 let task_attrib_done = Arc::clone(&attrib_done);
7712 let task_attrib_total = Arc::clone(&attrib_total);
7713
7714 {
7717 let mut runs = state.async_runs.lock().await;
7718 runs.insert(
7719 wait_id.clone(),
7720 AsyncRunState::Running {
7721 started_at: std::time::Instant::now(),
7722 cancel_token,
7723 phase,
7724 files_done,
7725 files_total,
7726 attrib_done,
7727 attrib_total,
7728 },
7729 );
7730 }
7731
7732 let task = AnalysisTask {
7733 sem_permit,
7734 state: state.clone(),
7735 wait_id: wait_id.clone(),
7736 config,
7737 cancel: task_cancel,
7738 phase: task_phase,
7739 files_done: task_files_done,
7740 files_total: task_files_total,
7741 attrib_done: task_attrib_done,
7742 attrib_total: task_attrib_total,
7743 git_repo: form.git_repo.clone().filter(|s| !s.is_empty()),
7744 git_ref: form.git_ref.clone().filter(|s| !s.is_empty()),
7745 project_path: form.path.clone(),
7746 output_dir: if state.server_mode {
7750 None
7751 } else {
7752 form.output_dir.clone()
7753 },
7754 clones_dir: state.git_clones_dir.clone(),
7755 cocomo_mode: form
7756 .cocomo_mode
7757 .clone()
7758 .unwrap_or_else(|| "organic".to_string()),
7759 complexity_alert: form
7760 .complexity_alert
7761 .as_deref()
7762 .and_then(|s| s.parse::<u32>().ok())
7763 .unwrap_or(0),
7764 exclude_duplicates: form.exclude_duplicates.as_deref() == Some("enabled"),
7765 };
7766
7767 tokio::spawn(run_analysis_task(task));
7768
7769 let template = ScanWaitTemplate {
7770 version: env!("CARGO_PKG_VERSION"),
7771 wait_id_json,
7772 project_path: form.path.clone(),
7773 csp_nonce,
7774 };
7775 let html = template
7776 .render()
7777 .unwrap_or_else(|err| format!("<pre>{err}</pre>"));
7778 let mut response = Html(html).into_response();
7779 if let Ok(name) = axum::http::HeaderName::from_bytes(b"x-wait-id")
7780 && let Ok(val) = axum::http::HeaderValue::from_str(&wait_id)
7781 {
7782 response.headers_mut().insert(name, val);
7783 }
7784 response
7785}
7786
7787struct AnalysisTask {
7788 sem_permit: tokio::sync::OwnedSemaphorePermit,
7789 state: AppState,
7790 wait_id: String,
7791 config: AppConfig,
7792 cancel: Arc<std::sync::atomic::AtomicBool>,
7793 phase: Arc<std::sync::Mutex<String>>,
7794 files_done: Arc<std::sync::atomic::AtomicUsize>,
7795 files_total: Arc<std::sync::atomic::AtomicUsize>,
7796 attrib_done: Arc<std::sync::atomic::AtomicUsize>,
7797 attrib_total: Arc<std::sync::atomic::AtomicUsize>,
7798 git_repo: Option<String>,
7799 git_ref: Option<String>,
7800 project_path: String,
7801 output_dir: Option<String>,
7802 clones_dir: PathBuf,
7803 cocomo_mode: String,
7804 complexity_alert: u32,
7805 exclude_duplicates: bool,
7806}
7807
7808#[allow(clippy::too_many_lines)] async fn run_analysis_task(task: AnalysisTask) {
7810 let _permit = task.sem_permit;
7811
7812 let cancel_sb = Arc::clone(&task.cancel);
7813 let (git_repo_sb, git_ref_sb) = (task.git_repo.clone(), task.git_ref.clone());
7814 let clones_dir_sb = task.clones_dir;
7815 let upload_staging_root = task
7817 .config
7818 .discovery
7819 .root_paths
7820 .first()
7821 .filter(|p| is_upload_tmp_path(p))
7822 .and_then(|p| p.parent().filter(|par| is_upload_tmp_path(par)))
7823 .map(PathBuf::from);
7824 let config_sb = task.config;
7825 let progress_sb = sloc_core::ProgressCounters {
7826 files_done: Arc::clone(&task.files_done),
7827 files_total: Arc::clone(&task.files_total),
7828 phase: Some(Arc::clone(&task.phase)),
7831 attrib_done: Arc::clone(&task.attrib_done),
7832 attrib_total: Arc::clone(&task.attrib_total),
7833 };
7834 if let Ok(mut p) = task.phase.lock() {
7835 *p = "Scanning files".to_string();
7836 }
7837 let analysis_result = tokio::task::spawn_blocking(move || {
7838 run_analysis_blocking(
7839 config_sb,
7840 git_repo_sb,
7841 git_ref_sb,
7842 clones_dir_sb,
7843 cancel_sb,
7844 Some(progress_sb),
7845 )
7846 })
7847 .await
7848 .map_err(|err| anyhow::anyhow!(err.to_string()))
7849 .and_then(|result| result);
7850
7851 if let Ok(mut p) = task.phase.lock() {
7852 *p = "Writing reports".to_string();
7853 }
7854
7855 if task.cancel.load(std::sync::atomic::Ordering::Relaxed) {
7857 let mut runs = task.state.async_runs.lock().await;
7858 if matches!(
7860 runs.get(&task.wait_id),
7861 Some(AsyncRunState::Running { .. } | AsyncRunState::Cancelled)
7862 ) {
7863 runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
7864 }
7865 drop(runs);
7866 return;
7867 }
7868
7869 let run = match analysis_result {
7870 Ok(v) => v,
7871 Err(err) => {
7872 if err.to_string().contains("analysis cancelled") {
7874 let mut runs = task.state.async_runs.lock().await;
7875 runs.insert(task.wait_id.clone(), AsyncRunState::Cancelled);
7876 drop(runs);
7877 return;
7878 }
7879 eprintln!("[oxide-sloc][analyze] analysis failed: {err:#}");
7880 let mut runs = task.state.async_runs.lock().await;
7881 runs.insert(
7882 task.wait_id.clone(),
7883 AsyncRunState::Failed {
7884 message: "Analysis failed. Check that the path exists and is readable."
7885 .to_string(),
7886 },
7887 );
7888 drop(runs);
7889 return;
7890 }
7891 };
7892
7893 let run_id = run.tool.run_id.clone();
7894 tracing::info!(event = "scan_complete", run_id = %run_id,
7895 path = %task.project_path, files = run.summary_totals.files_analyzed,
7896 "Analysis finished");
7897
7898 let prev_entry: Option<RegistryEntry> = {
7899 let reg = task.state.registry.lock().await;
7900 reg.entries_for_roots(&run.input_roots)
7901 .into_iter()
7902 .find(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
7903 .cloned()
7904 };
7905
7906 let scan_delta = prev_entry.as_ref().and_then(|prev| {
7907 prev.json_path
7908 .as_ref()
7909 .and_then(|p| read_json(p).ok())
7910 .map(|prev_run| compute_delta(&prev_run, &run))
7911 });
7912 let prev_scan_count: usize = {
7913 let reg = task.state.registry.lock().await;
7914 reg.entries_for_roots(&run.input_roots)
7915 .iter()
7916 .filter(|e| e.json_path.as_ref().is_some_and(|p| p.exists()))
7917 .count()
7918 };
7919
7920 let report_delta_ctx: Option<ReportDeltaContext> = scan_delta
7923 .as_ref()
7924 .zip(prev_entry.as_ref())
7925 .map(|(cmp, prev)| ReportDeltaContext {
7926 delta_code_added: sum_added_code_lines(cmp),
7927 delta_code_removed: sum_removed_code_lines(cmp),
7928 delta_unmodified_lines: sum_unmodified_code_lines(cmp),
7929 delta_files_added: cmp.files_added,
7930 delta_files_removed: cmp.files_removed,
7931 delta_files_modified: cmp.files_modified,
7932 delta_files_unchanged: cmp.files_unchanged,
7933 prev_code_lines: prev.summary.code_lines,
7934 prev_scan_count: prev_scan_count + 1,
7935 prev_scan_label: fmt_la_time(prev.timestamp_utc),
7936 prev_run_id: Some(prev.run_id.clone()),
7937 current_run_id: Some(run_id.clone()),
7938 });
7939 let report_html = match render_html_with_delta(&run, report_delta_ctx.as_ref()) {
7940 Ok(h) => h,
7941 Err(err) => {
7942 eprintln!("[oxide-sloc][analyze] HTML render failed: {err:#}");
7943 let mut runs = task.state.async_runs.lock().await;
7944 runs.insert(
7945 task.wait_id.clone(),
7946 AsyncRunState::Failed {
7947 message: "Failed to render HTML report.".to_string(),
7948 },
7949 );
7950 drop(runs);
7951 return;
7952 }
7953 };
7954
7955 let output_root = resolve_output_root(task.output_dir.as_deref());
7956 let project_label = derive_project_label(
7957 task.git_repo.as_deref(),
7958 task.git_ref.as_deref(),
7959 &task.project_path,
7960 );
7961 let dir_branch = if task.git_repo.as_deref().is_some_and(|s| !s.is_empty()) {
7964 None
7965 } else {
7966 run.git_branch.as_deref()
7967 };
7968 let run_dir = output_root.join(derive_run_dir_name(&project_label, dir_branch, &run_id));
7969 let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
7970
7971 let result_context = RunResultContext {
7972 prev_entry: prev_entry.clone(),
7973 prev_scan_count,
7974 project_path: task.project_path.clone(),
7975 cocomo_mode: task.cocomo_mode.clone(),
7976 complexity_alert: task.complexity_alert,
7977 exclude_duplicates: task.exclude_duplicates,
7978 };
7979
7980 let artifact_result = persist_run_artifacts(
7981 &run,
7982 &report_html,
7983 &run_dir,
7984 &run.effective_configuration.reporting.report_title,
7985 &file_stem,
7986 result_context,
7987 );
7988
7989 let (artifacts, pending_pdf) = match artifact_result {
7990 Ok(v) => v,
7991 Err(err) => {
7992 eprintln!("[oxide-sloc][analyze] artifact write failed: {err:#}");
7993 let mut runs = task.state.async_runs.lock().await;
7994 runs.insert(
7995 task.wait_id.clone(),
7996 AsyncRunState::Failed {
7997 message: "Failed to save report artifacts. Check available disk space."
7998 .to_string(),
7999 },
8000 );
8001 drop(runs);
8002 return;
8003 }
8004 };
8005
8006 {
8007 let mut map = task.state.artifacts.lock().await;
8008 map.insert(run_id.clone(), artifacts.clone());
8009 }
8010
8011 {
8012 let entry = build_run_registry_entry(&run, &run_id, &project_label, &artifacts);
8013 let mut reg = task.state.registry.lock().await;
8014 reg.add_entry(entry);
8015 let _ = reg.save(&task.state.registry_path);
8016 }
8017
8018 if let Some(ref cfg_path) = artifacts.scan_config_path {
8019 save_scan_config_json(
8020 cfg_path,
8021 &run,
8022 &task.project_path,
8023 task.output_dir.as_deref(),
8024 &task.cocomo_mode,
8025 task.complexity_alert,
8026 task.exclude_duplicates,
8027 );
8028 }
8029
8030 spawn_pdf_background(pending_pdf, run_id.clone(), task.state.artifacts.clone());
8031
8032 prom_runs_total().inc();
8033
8034 let mut runs = task.state.async_runs.lock().await;
8036 runs.insert(
8037 task.wait_id.clone(),
8038 AsyncRunState::Complete {
8039 run_id: run_id.clone(),
8040 },
8041 );
8042 drop(runs);
8043
8044 if let Some(staging) = upload_staging_root {
8047 let _ = tokio::fs::remove_dir_all(staging).await;
8048 }
8049
8050 let _ = scan_delta;
8051}
8052
8053fn save_scan_config_json(
8054 cfg_path: &std::path::Path,
8055 run: &sloc_core::AnalysisRun,
8056 project_path: &str,
8057 output_dir: Option<&str>,
8058 cocomo_mode: &str,
8059 complexity_alert: u32,
8060 exclude_duplicates: bool,
8061) {
8062 let policy_str = serde_json::to_value(run.effective_configuration.analysis.mixed_line_policy)
8063 .ok()
8064 .and_then(|v| v.as_str().map(String::from))
8065 .unwrap_or_else(|| "code_only".to_string());
8066 let behavior_str =
8067 serde_json::to_value(run.effective_configuration.analysis.binary_file_behavior)
8068 .ok()
8069 .and_then(|v| v.as_str().map(String::from))
8070 .unwrap_or_else(|| "skip".to_string());
8071 let continuation_policy_str = serde_json::to_value(
8072 run.effective_configuration
8073 .analysis
8074 .continuation_line_policy,
8075 )
8076 .ok()
8077 .and_then(|v| v.as_str().map(String::from))
8078 .unwrap_or_else(default_each_physical_line);
8079 let blank_policy_str = serde_json::to_value(
8080 run.effective_configuration
8081 .analysis
8082 .blank_in_block_comment_policy,
8083 )
8084 .ok()
8085 .and_then(|v| v.as_str().map(String::from))
8086 .unwrap_or_else(default_count_as_comment);
8087 let scan_cfg = ScanConfig {
8088 oxide_sloc_version: env!("CARGO_PKG_VERSION").to_string(),
8089 path: project_path.to_string(),
8090 include_globs: run
8091 .effective_configuration
8092 .discovery
8093 .include_globs
8094 .join("\n"),
8095 exclude_globs: run
8096 .effective_configuration
8097 .discovery
8098 .exclude_globs
8099 .join("\n"),
8100 submodule_breakdown: run.effective_configuration.discovery.submodule_breakdown,
8101 mixed_line_policy: policy_str,
8102 python_docstrings_as_comments: run
8103 .effective_configuration
8104 .analysis
8105 .python_docstrings_as_comments,
8106 generated_file_detection: run
8107 .effective_configuration
8108 .analysis
8109 .generated_file_detection,
8110 minified_file_detection: run.effective_configuration.analysis.minified_file_detection,
8111 vendor_directory_detection: run
8112 .effective_configuration
8113 .analysis
8114 .vendor_directory_detection,
8115 include_lockfiles: run.effective_configuration.analysis.include_lockfiles,
8116 binary_file_behavior: behavior_str,
8117 output_dir: output_dir.unwrap_or("").to_string(),
8118 report_title: run.effective_configuration.reporting.report_title.clone(),
8119 continuation_line_policy: continuation_policy_str,
8120 blank_in_block_comment_policy: blank_policy_str,
8121 count_compiler_directives: run
8122 .effective_configuration
8123 .analysis
8124 .count_compiler_directives,
8125 style_analysis_enabled: run.effective_configuration.analysis.style_analysis_enabled,
8126 style_col_threshold: run.effective_configuration.analysis.style_col_threshold,
8127 style_score_threshold: run.effective_configuration.analysis.style_score_threshold,
8128 style_lang_scope: run
8129 .effective_configuration
8130 .analysis
8131 .style_lang_scope
8132 .clone(),
8133 coverage_file: run
8134 .effective_configuration
8135 .analysis
8136 .coverage_file
8137 .as_ref()
8138 .map(|p| p.display().to_string())
8139 .unwrap_or_default(),
8140 cocomo_mode: cocomo_mode.to_string(),
8141 complexity_alert,
8142 exclude_duplicates,
8143 activity_window: run
8144 .effective_configuration
8145 .analysis
8146 .activity_window_days
8147 .unwrap_or(0),
8148 attribution: run.effective_configuration.analysis.attribution,
8149 };
8150 if let Ok(json) = serde_json::to_string_pretty(&scan_cfg) {
8151 let _ = std::fs::write(cfg_path, json);
8152 }
8153}
8154
8155#[allow(clippy::needless_pass_by_value)] fn run_analysis_blocking(
8157 mut config: AppConfig,
8158 git_repo: Option<String>,
8159 git_ref: Option<String>,
8160 clones_dir: PathBuf,
8161 cancel: Arc<std::sync::atomic::AtomicBool>,
8162 progress: Option<sloc_core::ProgressCounters>,
8163) -> Result<sloc_core::AnalysisRun> {
8164 if let (Some(repo), Some(refname)) = (git_repo, git_ref) {
8165 let dest = git_clone_dest(&repo, &clones_dir);
8166 sloc_git::clone_or_fetch(&repo, &dest)?;
8167 let wt = clones_dir.join(format!("wt-{}", uuid::Uuid::new_v4().simple()));
8168 sloc_git::create_worktree(&dest, &refname, &wt)?;
8169 config.discovery.root_paths = vec![wt.clone()];
8170 let run = analyze(&config, "serve", Some(&cancel), progress.as_ref());
8171 let _ = sloc_git::destroy_worktree(&dest, &wt);
8172 let mut run = run?;
8173 if run.git_branch.is_none() {
8174 run.git_branch = Some(refname);
8175 }
8176 return Ok(run);
8177 }
8178 analyze(&config, "serve", Some(&cancel), progress.as_ref())
8179}
8180
8181fn derive_project_label(
8182 git_repo: Option<&str>,
8183 git_ref: Option<&str>,
8184 fallback_path: &str,
8185) -> String {
8186 match (
8187 git_repo.filter(|s| !s.is_empty()),
8188 git_ref.filter(|s| !s.is_empty()),
8189 ) {
8190 (Some(repo), Some(refname)) => {
8191 let repo_name = repo
8192 .trim_end_matches('/')
8193 .trim_end_matches(".git")
8194 .rsplit('/')
8195 .next()
8196 .unwrap_or("repo");
8197 sanitize_project_label(&format!("{repo_name}_{refname}"))
8198 }
8199 _ => sanitize_project_label(fallback_path),
8200 }
8201}
8202
8203fn derive_run_dir_name(project_label: &str, branch: Option<&str>, run_id: &str) -> String {
8211 let short_run = match run_id.rsplit_once('-') {
8213 Some((stamp, uuid)) if uuid.len() > 8 && uuid.chars().all(|c| c.is_ascii_hexdigit()) => {
8214 format!("{stamp}-{}", &uuid[..8])
8215 }
8216 _ => run_id.to_string(),
8217 };
8218 let branch_part = branch
8219 .map(str::trim)
8220 .filter(|b| !b.is_empty())
8221 .map(|b| format!("-{}", sanitize_project_label(b)))
8222 .filter(|part| !project_label.contains(part.trim_start_matches('-')))
8223 .unwrap_or_default();
8224 format!("{project_label}{branch_part}-{short_run}")
8225}
8226
8227fn derive_file_stem(project_label: &str, commit_short: Option<&str>) -> String {
8228 let commit = commit_short.unwrap_or("").trim();
8229 if commit.is_empty() {
8230 project_label.to_string()
8231 } else {
8232 format!("{project_label}_{commit}")
8233 }
8234}
8235
8236#[derive(Serialize)]
8239#[serde(tag = "state", rename_all = "snake_case")]
8240enum AsyncRunStatusResponse {
8241 Running {
8242 elapsed_secs: u64,
8243 phase: String,
8244 files_done: u64,
8245 files_total: u64,
8246 attrib_done: u64,
8247 attrib_total: u64,
8248 },
8249 Complete {
8250 run_id: String,
8251 },
8252 Failed {
8253 message: String,
8254 },
8255 Cancelled,
8256}
8257
8258async fn async_run_status_handler(
8259 State(state): State<AppState>,
8260 AxumPath(wait_id): AxumPath<String>,
8261) -> Response {
8262 if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
8264 return error::bad_request("invalid wait_id");
8265 }
8266 let run_state = {
8267 let runs = state.async_runs.lock().await;
8268 runs.get(&wait_id).cloned()
8269 };
8270 match run_state {
8271 None => error::not_found("run not found"),
8272 Some(AsyncRunState::Running {
8273 started_at,
8274 phase,
8275 files_done,
8276 files_total,
8277 attrib_done,
8278 attrib_total,
8279 ..
8280 }) => {
8281 if started_at.elapsed() > std::time::Duration::from_hours(2) {
8283 let mut runs = state.async_runs.lock().await;
8284 runs.insert(
8285 wait_id,
8286 AsyncRunState::Failed {
8287 message: "Analysis timed out after 2 hours.".to_string(),
8288 },
8289 );
8290 drop(runs);
8291 return Json(AsyncRunStatusResponse::Failed {
8292 message: "Analysis timed out after 2 hours.".to_string(),
8293 })
8294 .into_response();
8295 }
8296 let phase_str = phase.lock().map(|g| g.clone()).unwrap_or_default();
8297 Json(AsyncRunStatusResponse::Running {
8298 elapsed_secs: started_at.elapsed().as_secs(),
8299 phase: phase_str,
8300 files_done: files_done.load(std::sync::atomic::Ordering::Relaxed) as u64,
8301 files_total: files_total.load(std::sync::atomic::Ordering::Relaxed) as u64,
8302 attrib_done: attrib_done.load(std::sync::atomic::Ordering::Relaxed) as u64,
8303 attrib_total: attrib_total.load(std::sync::atomic::Ordering::Relaxed) as u64,
8304 })
8305 .into_response()
8306 }
8307 Some(AsyncRunState::Complete { run_id }) => {
8308 Json(AsyncRunStatusResponse::Complete { run_id }).into_response()
8309 }
8310 Some(AsyncRunState::Failed { message }) => {
8311 Json(AsyncRunStatusResponse::Failed { message }).into_response()
8312 }
8313 Some(AsyncRunState::Cancelled) => Json(AsyncRunStatusResponse::Cancelled).into_response(),
8314 }
8315}
8316
8317async fn cancel_run_handler(
8318 State(state): State<AppState>,
8319 AxumPath(wait_id): AxumPath<String>,
8320) -> Response {
8321 if wait_id.len() > 128 || wait_id.contains('/') || wait_id.contains('\\') {
8322 return error::bad_request("invalid wait_id");
8323 }
8324 let mut runs = state.async_runs.lock().await;
8325 let resp = match runs.get(&wait_id) {
8326 Some(AsyncRunState::Running { cancel_token, .. }) => {
8327 cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
8328 runs.insert(wait_id, AsyncRunState::Cancelled);
8329 StatusCode::OK.into_response()
8330 }
8331 Some(AsyncRunState::Cancelled) => StatusCode::OK.into_response(),
8332 _ => error::not_found("run not found"),
8333 };
8334 drop(runs);
8335 resp
8336}
8337
8338async fn async_run_result_handler(
8339 State(state): State<AppState>,
8340 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
8341 AxumPath(run_id): AxumPath<String>,
8342) -> Response {
8343 if run_id.len() > 128 || run_id.contains('/') || run_id.contains('\\') {
8344 return StatusCode::BAD_REQUEST.into_response();
8345 }
8346
8347 let artifacts = {
8348 let map = state.artifacts.lock().await;
8349 map.get(&run_id).cloned()
8350 };
8351 let artifacts = if let Some(a) = artifacts {
8352 a
8353 } else {
8354 let reg = state.registry.lock().await;
8355 if let Some(entry) = reg.find_by_run_id(&run_id) {
8356 recover_artifacts_from_registry(entry)
8357 } else {
8358 let html = ErrorTemplate {
8359 message: format!(
8360 "Report not found. Run ID {} is not in the scan history.",
8361 &run_id[..run_id.len().min(8)]
8362 ),
8363 last_report_url: Some("/view-reports".to_string()),
8364 last_report_label: Some("View Reports".to_string()),
8365 run_id: Some(run_id.clone()),
8366 error_code: Some(404),
8367 csp_nonce: csp_nonce.clone(),
8368 version: env!("CARGO_PKG_VERSION"),
8369 }
8370 .render()
8371 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
8372 return (StatusCode::NOT_FOUND, Html(html)).into_response();
8373 }
8374 };
8375
8376 let json_path = if let Some(p) = &artifacts.json_path {
8377 p.clone()
8378 } else {
8379 let html = ErrorTemplate {
8380 message: "JSON result was not saved for this run.".to_string(),
8381 last_report_url: Some("/view-reports".to_string()),
8382 last_report_label: Some("View Reports".to_string()),
8383 run_id: Some(run_id.clone()),
8384 error_code: Some(404),
8385 csp_nonce: csp_nonce.clone(),
8386 version: env!("CARGO_PKG_VERSION"),
8387 }
8388 .render()
8389 .unwrap_or_else(|_| "<pre>No JSON.</pre>".to_string());
8390 return (StatusCode::NOT_FOUND, Html(html)).into_response();
8391 };
8392
8393 let Ok(mut run) = read_json(&json_path) else {
8394 let folder_hint = output_folder_hint(&json_path);
8395 let redirect_url = format!("/runs/result/{run_id}");
8396 return missing_scan_relocate_response(
8397 &format!(
8398 "Scan file could not be read:\n {}\n\nThe file may have been moved or \
8399 deleted. Browse to the folder containing your scan output to reconnect it.",
8400 json_path.display()
8401 ),
8402 &run_id,
8403 &folder_hint,
8404 &redirect_url,
8405 state.server_mode,
8406 &csp_nonce,
8407 );
8408 };
8409
8410 auto_merge_noreply_identities(&mut run);
8414 let merge_map = IdentityMap::load(&identities_path(&state));
8415 apply_identity_map(&mut run, &merge_map);
8416
8417 let confluence_configured = {
8418 let store = state.confluence.lock().await;
8419 store.is_configured()
8420 };
8421
8422 render_result_page(
8423 &run,
8424 &artifacts,
8425 &run_id,
8426 &csp_nonce,
8427 confluence_configured,
8428 state.server_mode,
8429 &merge_map.groups,
8430 )
8431}
8432
8433fn json_escape(s: &str) -> String {
8435 s.replace('\\', "\\\\").replace('"', "\\\"")
8436}
8437
8438struct LangTotals {
8440 physical_lines: u64,
8441 code_lines: u64,
8442 comment_lines: u64,
8443 blank_lines: u64,
8444 mixed_lines: u64,
8445 functions: u64,
8446 classes: u64,
8447 variables: u64,
8448 imports: u64,
8449}
8450
8451fn sum_lang_totals(run: &AnalysisRun) -> LangTotals {
8452 let s = |f: fn(&sloc_core::LanguageSummary) -> u64| -> u64 {
8453 run.totals_by_language.iter().map(f).sum()
8454 };
8455 LangTotals {
8456 physical_lines: s(|r| r.total_physical_lines),
8457 code_lines: s(|r| r.code_lines),
8458 comment_lines: s(|r| r.comment_lines),
8459 blank_lines: s(|r| r.blank_lines),
8460 mixed_lines: s(|r| r.mixed_lines_separate),
8461 functions: s(|r| r.functions),
8462 classes: s(|r| r.classes),
8463 variables: s(|r| r.variables),
8464 imports: s(|r| r.imports),
8465 }
8466}
8467
8468struct DeltaFields {
8470 prev_fa_str: String,
8471 prev_fs_str: String,
8472 prev_pl_str: String,
8473 prev_cl_str: String,
8474 prev_cml_str: String,
8475 prev_bl_str: String,
8476 delta_fa_str: String,
8477 delta_fa_class: String,
8478 delta_fs_str: String,
8479 delta_fs_class: String,
8480 delta_pl_str: String,
8481 delta_pl_class: String,
8482 delta_cl_str: String,
8483 delta_cl_class: String,
8484 delta_cml_str: String,
8485 delta_cml_class: String,
8486 delta_bl_str: String,
8487 delta_bl_class: String,
8488 delta_lines_added: Option<i64>,
8489 delta_lines_removed: Option<i64>,
8490 delta_lines_net_str: String,
8491 delta_lines_net_class: String,
8492}
8493
8494#[allow(
8499 clippy::similar_names,
8500 reason = "locals mirror template-bound struct fields"
8501)]
8502fn compute_delta_fields(
8503 prev_entry: Option<&RegistryEntry>,
8504 totals: &LangTotals,
8505 files_analyzed: u64,
8506 files_skipped: u64,
8507 scan_delta: Option<&sloc_core::ScanComparison>,
8508) -> DeltaFields {
8509 let prev_sum = prev_entry.map(|e| &e.summary);
8510 let fmt_prev = |opt: Option<u64>| opt.map_or_else(|| "\u{2014}".into(), |v| v.to_string());
8511
8512 let (delta_fa_str, delta_fa_class) =
8513 summary_delta(files_analyzed, prev_sum.map(|s| s.files_analyzed));
8514 let (delta_fs_str, delta_fs_class) =
8515 summary_delta(files_skipped, prev_sum.map(|s| s.files_skipped));
8516 let (delta_pl_str, delta_pl_class) = summary_delta(
8517 totals.physical_lines,
8518 prev_sum.map(|s| s.total_physical_lines),
8519 );
8520 let (delta_cl_str, delta_cl_class) =
8521 summary_delta(totals.code_lines, prev_sum.map(|s| s.code_lines));
8522 let (delta_cml_str, delta_cml_class) =
8523 summary_delta(totals.comment_lines, prev_sum.map(|s| s.comment_lines));
8524 let (delta_bl_str, delta_bl_class) =
8525 summary_delta(totals.blank_lines, prev_sum.map(|s| s.blank_lines));
8526
8527 let delta_lines_added = scan_delta.map(sum_added_code_lines);
8528 let delta_lines_removed = scan_delta.map(sum_removed_code_lines);
8529 let (delta_lines_net_str, delta_lines_net_class) =
8530 match (delta_lines_added, delta_lines_removed) {
8531 (Some(a), Some(r)) => {
8532 let net = a - r;
8533 (fmt_delta(net), delta_class(net).to_string())
8534 }
8535 _ => ("\u{2014}".to_string(), "na".to_string()),
8536 };
8537
8538 DeltaFields {
8539 prev_fa_str: fmt_prev(prev_sum.map(|s| s.files_analyzed)),
8540 prev_fs_str: fmt_prev(prev_sum.map(|s| s.files_skipped)),
8541 prev_pl_str: fmt_prev(prev_sum.map(|s| s.total_physical_lines)),
8542 prev_cl_str: fmt_prev(prev_sum.map(|s| s.code_lines)),
8543 prev_cml_str: fmt_prev(prev_sum.map(|s| s.comment_lines)),
8544 prev_bl_str: fmt_prev(prev_sum.map(|s| s.blank_lines)),
8545 delta_fa_str,
8546 delta_fa_class: delta_fa_class.to_string(),
8547 delta_fs_str,
8548 delta_fs_class: delta_fs_class.to_string(),
8549 delta_pl_str,
8550 delta_pl_class: delta_pl_class.to_string(),
8551 delta_cl_str,
8552 delta_cl_class: delta_cl_class.to_string(),
8553 delta_cml_str,
8554 delta_cml_class: delta_cml_class.to_string(),
8555 delta_bl_str,
8556 delta_bl_class: delta_bl_class.to_string(),
8557 delta_lines_added,
8558 delta_lines_removed,
8559 delta_lines_net_str,
8560 delta_lines_net_class,
8561 }
8562}
8563
8564fn delta_unmodified_lines(scan_delta: &sloc_core::ScanComparison) -> u64 {
8566 scan_delta
8567 .file_deltas
8568 .iter()
8569 .filter(|f| f.status == sloc_core::FileChangeStatus::Unchanged)
8570 .map(|f| {
8571 #[allow(clippy::cast_sign_loss)]
8572 let n = f.current_code as u64;
8573 n
8574 })
8575 .sum()
8576}
8577
8578fn git_commit_url_for(run: &AnalysisRun) -> Option<String> {
8579 run.git_remote_url
8580 .as_deref()
8581 .zip(run.git_commit_long.as_deref())
8582 .and_then(|(remote, sha)| remote_to_commit_url(remote, sha))
8583}
8584
8585fn git_branch_url_for(run: &AnalysisRun) -> Option<String> {
8586 run.git_remote_url
8587 .as_deref()
8588 .zip(run.git_branch.as_deref())
8589 .and_then(|(remote, branch)| remote_to_branch_url(remote, branch))
8590}
8591
8592fn scan_performed_by(run: &AnalysisRun) -> String {
8593 run.environment.ci_name.clone().unwrap_or_else(|| {
8594 format!(
8595 "{} / {}",
8596 run.environment.initiator_username, run.environment.initiator_hostname
8597 )
8598 })
8599}
8600
8601fn build_lang_chart_json(run: &AnalysisRun) -> String {
8603 let mut langs: Vec<&sloc_core::LanguageSummary> = run.totals_by_language.iter().collect();
8604 langs.sort_by_key(|l| std::cmp::Reverse(l.code_lines));
8605 let entries: Vec<String> = langs
8606 .into_iter()
8607 .take(12)
8608 .map(|l| {
8609 let name = json_escape(l.language.display_name());
8610 format!(
8611 r#"{{"lang":"{}","code":{},"comments":{},"blanks":{},"physical":{},"functions":{},"classes":{},"variables":{},"imports":{},"files":{}}}"#,
8612 name,
8613 l.code_lines,
8614 l.comment_lines,
8615 l.blank_lines,
8616 l.total_physical_lines,
8617 l.functions,
8618 l.classes,
8619 l.variables,
8620 l.imports,
8621 l.files,
8622 )
8623 })
8624 .collect();
8625 format!("[{}]", entries.join(","))
8626}
8627
8628fn build_scatter_chart_json(run: &AnalysisRun) -> String {
8630 let entries: Vec<String> = run
8631 .totals_by_language
8632 .iter()
8633 .map(|l| {
8634 let name = json_escape(l.language.display_name());
8635 format!(
8636 r#"{{"lang":"{}","files":{},"code":{},"physical":{}}}"#,
8637 name, l.files, l.code_lines, l.total_physical_lines,
8638 )
8639 })
8640 .collect();
8641 format!("[{}]", entries.join(","))
8642}
8643
8644fn build_semantic_chart_json(run: &AnalysisRun) -> String {
8646 let entries: Vec<String> = run
8647 .totals_by_language
8648 .iter()
8649 .filter(|l| {
8650 l.functions > 0 || l.classes > 0 || l.variables > 0 || l.imports > 0 || l.test_count > 0
8651 })
8652 .map(|l| {
8653 let name = json_escape(l.language.display_name());
8654 format!(
8655 r#"{{"lang":"{}","functions":{},"classes":{},"variables":{},"imports":{},"tests":{}}}"#,
8656 name, l.functions, l.classes, l.variables, l.imports, l.test_count,
8657 )
8658 })
8659 .collect();
8660 format!("[{}]", entries.join(","))
8661}
8662
8663fn build_submodule_chart_json(run: &AnalysisRun) -> String {
8665 let entries: Vec<String> = run
8666 .submodule_summaries
8667 .iter()
8668 .map(|s| {
8669 let name = json_escape(&s.name);
8670 format!(
8671 r#"{{"name":"{}","code":{},"comment":{},"blank":{},"physical":{},"files":{}}}"#,
8672 name,
8673 s.code_lines,
8674 s.comment_lines,
8675 s.blank_lines,
8676 s.total_physical_lines,
8677 s.files_analyzed,
8678 )
8679 })
8680 .collect();
8681 format!("[{}]", entries.join(","))
8682}
8683
8684#[allow(clippy::cast_precision_loss)]
8686fn cov_pct_str(hit: u64, found: u64) -> String {
8687 if found > 0 {
8688 format!("{:.1}", hit as f64 / found as f64 * 100.0)
8689 } else {
8690 String::new()
8691 }
8692}
8693
8694fn cov_lines_summary_str(hit: u64, found: u64) -> String {
8696 if found > 0 {
8697 format!("{hit} / {found}")
8698 } else {
8699 String::new()
8700 }
8701}
8702
8703const fn cocomo_coefficients(mode: sloc_core::CocomoMode) -> (f64, f64, f64, f64) {
8704 use sloc_core::CocomoMode;
8705 match mode {
8706 CocomoMode::SemiDetached => (3.0, 1.12, 2.5, 0.35),
8707 CocomoMode::Embedded => (3.6, 1.20, 2.5, 0.32),
8708 CocomoMode::Organic => (2.4, 1.05, 2.5, 0.38),
8709 }
8710}
8711
8712const fn cocomo_mode_label(mode: sloc_core::CocomoMode) -> &'static str {
8713 use sloc_core::CocomoMode;
8714 match mode {
8715 CocomoMode::Organic => "Organic",
8716 CocomoMode::SemiDetached => "Semi-detached",
8717 CocomoMode::Embedded => "Embedded",
8718 }
8719}
8720
8721const fn cocomo_mode_tooltip(mode: sloc_core::CocomoMode) -> &'static str {
8722 use sloc_core::CocomoMode;
8723 match mode {
8724 CocomoMode::Organic => {
8725 "Organic: A small team working on a well-understood project in a familiar \
8726 environment with minimal external constraints. Suited for internal tools, \
8727 utilities, and projects with stable requirements. Effort = 2.4 \u{00D7} KSLOC^1.05."
8728 }
8729 CocomoMode::SemiDetached => {
8730 "Semi-detached: A mixed team with varying experience tackling a project with \
8731 moderate novelty and some rigid constraints. Typical for compilers, transaction \
8732 systems, and batch processors. Effort = 3.0 \u{00D7} KSLOC^1.12."
8733 }
8734 CocomoMode::Embedded => {
8735 "Embedded: Tight hardware, software, or operational constraints requiring \
8736 significant innovation and deep integration work. Typical for real-time control \
8737 systems and safety-critical software. Effort = 3.6 \u{00D7} KSLOC^1.20."
8738 }
8739 }
8740}
8741
8742struct CocomoFields {
8744 has_cocomo: bool,
8745 effort_str: String,
8746 duration_str: String,
8747 staff_str: String,
8748 ksloc_str: String,
8749 mode_label: String,
8750 mode_tooltip: String,
8751}
8752
8753#[allow(clippy::cast_precision_loss)]
8754fn recompute_cocomo(run: &AnalysisRun, mode_str: &str) -> CocomoFields {
8755 use sloc_core::CocomoMode;
8756 let mode = match mode_str {
8757 "semi_detached" => CocomoMode::SemiDetached,
8758 "embedded" => CocomoMode::Embedded,
8759 _ => CocomoMode::Organic,
8760 };
8761 let (a, b, c, d) = cocomo_coefficients(mode);
8762 let ksloc = run.summary_totals.code_lines as f64 / 1_000.0;
8763 let effort = a * ksloc.powf(b);
8764 let duration = c * effort.powf(d);
8765 let staff = if duration > 0.0 {
8766 effort / duration
8767 } else {
8768 0.0
8769 };
8770 let round2 = |x: f64| format!("{:.2}", (x * 100.0).round() / 100.0);
8771 let mode_label = cocomo_mode_label(mode).to_string();
8772 let mode_tooltip = cocomo_mode_tooltip(mode).to_string();
8773 if run.summary_totals.code_lines > 0 {
8774 CocomoFields {
8775 has_cocomo: true,
8776 effort_str: round2(effort),
8777 duration_str: round2(duration),
8778 staff_str: round2(staff),
8779 ksloc_str: round2(ksloc),
8780 mode_label,
8781 mode_tooltip,
8782 }
8783 } else {
8784 CocomoFields {
8785 has_cocomo: false,
8786 effort_str: String::new(),
8787 duration_str: String::new(),
8788 staff_str: String::new(),
8789 ksloc_str: String::new(),
8790 mode_label,
8791 mode_tooltip,
8792 }
8793 }
8794}
8795
8796#[allow(clippy::too_many_lines)]
8797#[allow(clippy::similar_names)] #[allow(clippy::cast_precision_loss)] fn render_result_ownership_section(
8805 run: &AnalysisRun,
8806 merge_groups: &[AuthorMergeGroup],
8807 redirect_to: &str,
8808) -> String {
8809 use std::fmt::Write as _;
8810 let total_code: u64 = run.authors.iter().map(|a| a.counts.code_lines).sum();
8811 let rows = build_ownership_rows(Some(run), total_code);
8812 if rows.is_empty() {
8813 return String::new();
8814 }
8815 let name_link = |r: &OwnershipRow| -> String {
8816 let name = own_esc(&r.name);
8817 match r.profile.as_deref() {
8818 Some(url) => format!(
8819 r#"<a class="author-link" href="{url}" target="_blank" rel="noopener" title="View {name}'s profile / contributions">{name}</a>"#,
8820 url = own_esc(url),
8821 name = name,
8822 ),
8823 None => name,
8824 }
8825 };
8826 let mut author_table = String::new();
8827 for r in &rows {
8828 let _ = write!(
8829 author_table,
8830 r#"<tr><td><span class="own-dot" data-sx-style="background:{color};"></span>{name}</td><td><span class="own-email">{email}</span></td><td class="num">{code}</td><td class="num">{comment}</td><td class="num">{blank}</td><td class="num">{total}</td><td class="num own-pct">{pct:.1}%</td><td class="num">{files}</td></tr>"#,
8831 color = r.color,
8832 name = name_link(r),
8833 email = own_esc(&r.email),
8834 code = fmt_num(r.code as i64),
8835 comment = fmt_num(r.comment as i64),
8836 blank = fmt_num(r.blank as i64),
8837 total = fmt_num(r.total as i64),
8838 pct = r.code_pct,
8839 files = r.files_owned,
8840 );
8841 }
8842 let merge_panel = render_merge_panel(merge_groups, &rows, redirect_to);
8843 let plural = if rows.len() == 1 { "" } else { "s" };
8844 format!(
8845 r#"<div class="cocomo-box own-result-box">
8846 <div class="cocomo-box-head">
8847 <span class="cocomo-box-title">Code Ownership</span>
8848 <span class="own-result-count">{count} contributor{plural}</span>
8849 </div>
8850 <p class="cocomo-box-note own-result-intro">Per-author line ownership from <strong>git blame</strong>. GitHub no-reply aliases fold into a contributor's real email automatically; use <strong>Combine contributors</strong> below to merge across different emails — applied on top of the scan with no re-scan, and reflected right here on this report.</p>
8851 <div class="own-result-scroll">
8852 <table class="own-result-table">
8853 <thead><tr><th>Author</th><th>Email</th><th class="num">Code</th><th class="num">Comment</th><th class="num">Blank</th><th class="num">Total</th><th class="num">Code %</th><th class="num">Files</th></tr></thead>
8854 <tbody>{author_table}</tbody>
8855 </table>
8856 </div>
8857 {merge_panel}
8858 </div>"#,
8859 count = rows.len(),
8860 plural = plural,
8861 author_table = author_table,
8862 merge_panel = merge_panel,
8863 )
8864}
8865
8866fn render_result_page(
8867 run: &AnalysisRun,
8868 artifacts: &RunArtifacts,
8869 run_id: &str,
8870 csp_nonce: &str,
8871 confluence_configured: bool,
8872 server_mode: bool,
8873 merge_groups: &[AuthorMergeGroup],
8874) -> Response {
8875 let ctx = &artifacts.result_context;
8876 let prev_entry = &ctx.prev_entry;
8877 let prev_scan_count = ctx.prev_scan_count;
8878 let project_path_owned = if ctx.project_path.is_empty() {
8882 run.input_roots.join(", ")
8883 } else {
8884 ctx.project_path.clone()
8885 };
8886 let project_path = &project_path_owned;
8887
8888 let scan_delta = prev_entry.as_ref().and_then(|prev| {
8889 prev.json_path
8890 .as_ref()
8891 .and_then(|p| read_json(p).ok())
8892 .map(|prev_run| compute_delta(&prev_run, run))
8893 });
8894
8895 let files_analyzed = run.per_file_records.len() as u64;
8896 let files_skipped = run.skipped_file_records.len() as u64;
8897 let totals = sum_lang_totals(run);
8898
8899 let DeltaFields {
8900 prev_fa_str,
8901 prev_fs_str,
8902 prev_pl_str,
8903 prev_cl_str,
8904 prev_cml_str,
8905 prev_bl_str,
8906 delta_fa_str,
8907 delta_fa_class,
8908 delta_fs_str,
8909 delta_fs_class,
8910 delta_pl_str,
8911 delta_pl_class,
8912 delta_cl_str,
8913 delta_cl_class,
8914 delta_cml_str,
8915 delta_cml_class,
8916 delta_bl_str,
8917 delta_bl_class,
8918 delta_lines_added,
8919 delta_lines_removed,
8920 delta_lines_net_str,
8921 delta_lines_net_class,
8922 } = compute_delta_fields(
8923 prev_entry.as_ref(),
8924 &totals,
8925 files_analyzed,
8926 files_skipped,
8927 scan_delta.as_ref(),
8928 );
8929
8930 let run_dir = artifacts.output_dir.clone();
8931 let git_branch = run.git_branch.clone();
8932 let git_commit = run.git_commit_short.clone();
8933 let git_commit_long = run.git_commit_long.clone();
8934 let git_author = run.git_commit_author.clone();
8935 let git_commit_url = git_commit_url_for(run);
8936 let git_branch_url = git_branch_url_for(run);
8937 let scan_performed_by = scan_performed_by(run);
8938 let scan_time_display = fmt_la_time_meta(run.tool.timestamp_utc);
8939 let os_display = format!(
8940 "{} / {}",
8941 run.environment.operating_system, run.environment.architecture
8942 );
8943 let test_count = run.summary_totals.test_count;
8944
8945 let cyclomatic_complexity = run.summary_totals.cyclomatic_complexity;
8947 let lsloc = run.summary_totals.lsloc;
8948 let uloc = run.uloc;
8949 let dryness_pct_str = run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}"));
8950 let duplicate_group_count = run.duplicate_groups.len();
8951
8952 let ctx = &artifacts.result_context;
8954 let CocomoFields {
8955 has_cocomo,
8956 effort_str: cocomo_effort_str,
8957 duration_str: cocomo_duration_str,
8958 staff_str: cocomo_staff_str,
8959 ksloc_str: cocomo_ksloc_str,
8960 mode_label: cocomo_mode_label,
8961 mode_tooltip: cocomo_mode_tooltip,
8962 } = recompute_cocomo(run, ctx.cocomo_mode.as_str());
8963 let complexity_alert = ctx.complexity_alert;
8964
8965 let ownership_html =
8966 render_result_ownership_section(run, merge_groups, &format!("/runs/result/{run_id}"));
8967
8968 let template = ResultTemplate {
8969 version: env!("CARGO_PKG_VERSION"),
8970 report_title: run.effective_configuration.reporting.report_title.clone(),
8971 project_path: project_path.clone(),
8972 output_dir: display_path(&artifacts.output_dir),
8973 run_id: run_id.to_owned(),
8974 run_id_short: run_id
8975 .split('-')
8976 .next_back()
8977 .unwrap_or(run_id)
8978 .chars()
8979 .take(7)
8980 .collect(),
8981 files_analyzed,
8982 files_skipped,
8983 physical_lines: totals.physical_lines,
8984 code_lines: totals.code_lines,
8985 comment_lines: totals.comment_lines,
8986 blank_lines: totals.blank_lines,
8987 mixed_lines: totals.mixed_lines,
8988 functions: totals.functions,
8989 classes: totals.classes,
8990 variables: totals.variables,
8991 imports: totals.imports,
8992 html_url: artifacts
8993 .html_path
8994 .as_ref()
8995 .map(|_| format!("/runs/html/{run_id}")),
8996 pdf_url: artifacts
8997 .pdf_path
8998 .as_ref()
8999 .map(|_| format!("/runs/pdf/{run_id}")),
9000 json_url: artifacts
9001 .json_path
9002 .as_ref()
9003 .map(|_| format!("/runs/json/{run_id}")),
9004 html_download_url: artifacts
9005 .html_path
9006 .as_ref()
9007 .map(|_| format!("/runs/html/{run_id}?download=1")),
9008 pdf_download_url: artifacts
9009 .pdf_path
9010 .as_ref()
9011 .map(|_| format!("/runs/pdf/{run_id}?download=1")),
9012 json_download_url: artifacts
9013 .json_path
9014 .as_ref()
9015 .map(|_| format!("/runs/json/{run_id}?download=1")),
9016 html_path: artifacts.html_path.as_ref().map(|p| display_path(p)),
9017 json_path: artifacts.json_path.as_ref().map(|p| display_path(p)),
9018 prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
9019 prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
9020 prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
9021 prev_fa_str,
9022 prev_fs_str,
9023 prev_pl_str,
9024 prev_cl_str,
9025 prev_cml_str,
9026 prev_bl_str,
9027 delta_fa_str,
9028 delta_fa_class,
9029 delta_fs_str,
9030 delta_fs_class,
9031 delta_pl_str,
9032 delta_pl_class,
9033 delta_cl_str,
9034 delta_cl_class,
9035 delta_cml_str,
9036 delta_cml_class,
9037 delta_bl_str,
9038 delta_bl_class,
9039 delta_lines_added,
9040 delta_lines_removed,
9041 delta_lines_net_str,
9042 delta_lines_net_class,
9043 delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
9044 delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
9045 delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
9046 delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
9047 delta_files_total: scan_delta.as_ref().map(|d| d.files_total),
9048 delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
9049 git_branch,
9050 git_branch_url,
9051 git_commit,
9052 git_commit_long,
9053 git_author,
9054 git_commit_url,
9055 scan_performed_by,
9056 scan_time_display,
9057 scan_time_utc_ms: run.tool.timestamp_utc.timestamp_millis(),
9058 os_display,
9059 test_count,
9060 test_assertion_count: run.summary_totals.test_assertion_count,
9061 current_scan_number: prev_scan_count + 1,
9062 prev_scan_count,
9063 submodule_rows: run
9064 .submodule_summaries
9065 .iter()
9066 .map(|s| build_submodule_row(s, run, run_id, &run_dir))
9067 .collect(),
9068 pdf_generating: artifacts.pdf_path.as_ref().is_some_and(|p| !p.exists()),
9069 scan_config_url: format!("/runs/scan-config/{run_id}"),
9070 lang_chart_json: build_lang_chart_json(run),
9071 scatter_chart_json: build_scatter_chart_json(run),
9072 semantic_chart_json: build_semantic_chart_json(run),
9073 submodule_chart_json: build_submodule_chart_json(run),
9074 has_submodule_data: !run.submodule_summaries.is_empty(),
9075 has_semantic_data: run
9076 .totals_by_language
9077 .iter()
9078 .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
9079 csp_nonce: csp_nonce.to_owned(),
9080 confluence_configured,
9081 server_mode,
9082 report_header_footer: run
9083 .effective_configuration
9084 .reporting
9085 .report_header_footer
9086 .clone(),
9087 is_offline: false,
9088 cyclomatic_complexity,
9089 lsloc,
9090 uloc,
9091 dryness_pct_str,
9092 duplicate_group_count,
9093 has_cocomo,
9094 cocomo_effort_str,
9095 cocomo_duration_str,
9096 cocomo_staff_str,
9097 cocomo_ksloc_str,
9098 cocomo_mode_label,
9099 cocomo_mode_tooltip,
9100 complexity_alert,
9101 has_coverage_data: run.summary_totals.coverage_lines_found > 0,
9102 cov_line_pct: cov_pct_str(
9103 run.summary_totals.coverage_lines_hit,
9104 run.summary_totals.coverage_lines_found,
9105 ),
9106 cov_fn_pct: cov_pct_str(
9107 run.summary_totals.coverage_functions_hit,
9108 run.summary_totals.coverage_functions_found,
9109 ),
9110 cov_branch_pct: cov_pct_str(
9111 run.summary_totals.coverage_branches_hit,
9112 run.summary_totals.coverage_branches_found,
9113 ),
9114 cov_lines_summary: cov_lines_summary_str(
9115 run.summary_totals.coverage_lines_hit,
9116 run.summary_totals.coverage_lines_found,
9117 ),
9118 ownership_html,
9119 };
9120
9121 Html(
9122 template
9123 .render()
9124 .unwrap_or_else(|err| format!("<pre>{err}</pre>")),
9125 )
9126 .into_response()
9127}
9128
9129fn build_pdf_filename(report_title: &str, run_id: &str) -> String {
9130 let slug: String = report_title
9131 .chars()
9132 .map(|c| {
9133 if c.is_alphanumeric() || c == '-' {
9134 c.to_ascii_lowercase()
9135 } else {
9136 '_'
9137 }
9138 })
9139 .collect::<String>()
9140 .split('_')
9141 .filter(|s| !s.is_empty())
9142 .collect::<Vec<_>>()
9143 .join("_");
9144
9145 let short_id = run_id.rsplit('-').next().unwrap_or(run_id);
9146
9147 if slug.is_empty() {
9148 format!("report_{short_id}.pdf")
9149 } else {
9150 format!("{slug}_{short_id}.pdf")
9151 }
9152}
9153
9154#[derive(Serialize)]
9155struct PdfStatusResponse {
9156 ready: bool,
9157}
9158
9159async fn pdf_status_handler(
9162 State(state): State<AppState>,
9163 AxumPath(run_id): AxumPath<String>,
9164) -> Response {
9165 let pdf_path = {
9166 let registry = state.artifacts.lock().await;
9167 registry.get(&run_id).and_then(|a| a.pdf_path.clone())
9168 };
9169 let pdf_path = if pdf_path.is_some() {
9170 pdf_path
9171 } else {
9172 let reg = state.registry.lock().await;
9173 reg.find_by_run_id(&run_id)
9174 .map(recover_artifacts_from_registry)
9175 .and_then(|a| a.pdf_path)
9176 };
9177 let ready = pdf_path.is_some_and(|p| p.exists());
9178 Json(PdfStatusResponse { ready }).into_response()
9179}
9180
9181async fn download_bundle_handler(
9187 State(state): State<AppState>,
9188 AxumPath(run_id): AxumPath<String>,
9189) -> Response {
9190 let output_dir = {
9192 let cache = state.artifacts.lock().await;
9193 cache.get(&run_id).map(|a| a.output_dir.clone())
9194 };
9195 let output_dir = if let Some(d) = output_dir {
9196 d
9197 } else {
9198 let reg = state.registry.lock().await;
9199 match reg.find_by_run_id(&run_id) {
9200 Some(entry) => recover_artifacts_from_registry(entry).output_dir,
9201 None => {
9202 return (
9203 StatusCode::NOT_FOUND,
9204 Json(serde_json::json!({"error": "Run not found"})),
9205 )
9206 .into_response();
9207 }
9208 }
9209 };
9210
9211 if !output_dir.exists() {
9212 return (
9213 StatusCode::NOT_FOUND,
9214 Json(serde_json::json!({"error": "Output directory no longer exists on disk"})),
9215 )
9216 .into_response();
9217 }
9218
9219 let run_id_clone = run_id.clone();
9221 let archive_result = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
9222 use flate2::{Compression, write::GzEncoder};
9223 let mut enc = GzEncoder::new(Vec::new(), Compression::default());
9224 {
9225 let mut tar = tar::Builder::new(&mut enc);
9226 tar.follow_symlinks(false);
9227 if let Ok(entries) = std::fs::read_dir(&output_dir) {
9230 for entry in entries.filter_map(Result::ok) {
9231 let p = entry.path();
9232 if p.is_file() {
9233 let name = p.file_name().unwrap_or_default().to_string_lossy();
9234 let archive_path = format!("{run_id_clone}/{name}");
9235 tar.append_path_with_name(&p, &archive_path)?;
9236 }
9237 }
9238 }
9239 tar.finish()?;
9240 }
9241 Ok(enc.finish()?)
9242 })
9243 .await;
9244
9245 match archive_result {
9246 Ok(Ok(bytes)) => {
9247 let filename = format!("oxide-sloc-{}.tar.gz", &run_id[..run_id.len().min(8)]);
9248 axum::response::Response::builder()
9249 .status(StatusCode::OK)
9250 .header("Content-Type", "application/gzip")
9251 .header(
9252 "Content-Disposition",
9253 format!("attachment; filename=\"{filename}\""),
9254 )
9255 .header("Content-Length", bytes.len().to_string())
9256 .body(axum::body::Body::from(bytes))
9257 .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
9258 }
9259 Ok(Err(e)) => (
9260 StatusCode::INTERNAL_SERVER_ERROR,
9261 Json(serde_json::json!({"error": format!("Archive build failed: {e}")})),
9262 )
9263 .into_response(),
9264 Err(e) => (
9265 StatusCode::INTERNAL_SERVER_ERROR,
9266 Json(serde_json::json!({"error": format!("Task panicked: {e}")})),
9267 )
9268 .into_response(),
9269 }
9270}
9271
9272async fn delete_run_handler(
9277 State(state): State<AppState>,
9278 AxumPath(run_id): AxumPath<String>,
9279) -> Response {
9280 let output_dir = {
9282 let mut cache = state.artifacts.lock().await;
9283 let dir = cache.get(&run_id).map(|a| a.output_dir.clone());
9284 cache.remove(&run_id);
9285 dir
9286 };
9287 let output_dir = if let Some(d) = output_dir {
9288 d
9289 } else {
9290 let reg = state.registry.lock().await;
9291 reg.find_by_run_id(&run_id)
9292 .map(|e| recover_artifacts_from_registry(e).output_dir)
9293 .unwrap_or_default()
9294 };
9295
9296 {
9298 let mut reg = state.registry.lock().await;
9299 reg.entries.retain(|e| e.run_id != run_id);
9300 let _ = reg.save(&state.registry_path);
9301 }
9302
9303 if output_dir.exists() {
9306 match tokio::fs::remove_dir_all(&output_dir).await {
9307 Ok(()) => {}
9308 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
9309 Err(e) => {
9310 return (
9311 StatusCode::INTERNAL_SERVER_ERROR,
9312 Json(serde_json::json!({"error": format!("Failed to delete files: {e}")})),
9313 )
9314 .into_response();
9315 }
9316 }
9317 }
9318
9319 StatusCode::NO_CONTENT.into_response()
9320}
9321
9322fn export_share_dir() -> Option<PathBuf> {
9329 std::env::var("SLOC_EXPORT_DIR")
9330 .ok()
9331 .map(|s| s.trim().to_owned())
9332 .filter(|s| !s.is_empty())
9333 .map(PathBuf::from)
9334}
9335
9336fn export_auto_enabled() -> bool {
9340 matches!(
9341 std::env::var("SLOC_EXPORT_AUTO").as_deref(),
9342 Ok("1" | "true" | "TRUE")
9343 )
9344}
9345
9346#[derive(Debug, Deserialize, Default)]
9347struct ExportQuery {
9348 target: Option<String>,
9350}
9351
9352async fn resolve_run_output_dir(state: &AppState, run_id: &str) -> PathBuf {
9355 if let Some(d) = state
9356 .artifacts
9357 .lock()
9358 .await
9359 .get(run_id)
9360 .map(|a| a.output_dir.clone())
9361 {
9362 return d;
9363 }
9364 let reg = state.registry.lock().await;
9365 reg.find_by_run_id(run_id)
9366 .map(|e| recover_artifacts_from_registry(e).output_dir)
9367 .unwrap_or_default()
9368}
9369
9370async fn export_run_to_share(output_dir: PathBuf) -> Response {
9373 let Some(dest_root) = export_share_dir() else {
9374 return (
9375 StatusCode::CONFLICT,
9376 Json(serde_json::json!({
9377 "error": "Share export is not configured. Set SLOC_EXPORT_DIR to a destination \
9378 directory (e.g. a mounted network share) and restart."
9379 })),
9380 )
9381 .into_response();
9382 };
9383 let sub = output_dir
9384 .file_name()
9385 .map_or_else(|| "export".into(), std::ffi::OsStr::to_os_string);
9386 let dest = dest_root.join(&sub);
9387 let src = output_dir.clone();
9388 let dest_for_task = dest.clone();
9389 match tokio::task::spawn_blocking(move || sloc_core::copy_tree(&src, &dest_for_task)).await {
9390 Ok(Ok((files, bytes))) => {
9391 audit::record(
9392 "run_exported",
9393 "success",
9394 &[
9395 ("target", "share"),
9396 ("dest", &dest.display().to_string()),
9397 ("files", &files.to_string()),
9398 ],
9399 );
9400 Json(serde_json::json!({
9401 "target": "share",
9402 "dest": dest.display().to_string(),
9403 "files": files,
9404 "bytes": bytes,
9405 }))
9406 .into_response()
9407 }
9408 Ok(Err(e)) => {
9409 tracing::warn!(event = "export_error", "share export failed: {e}");
9410 (
9411 StatusCode::INTERNAL_SERVER_ERROR,
9412 Json(serde_json::json!({"error": format!("Export failed: {e}")})),
9413 )
9414 .into_response()
9415 }
9416 Err(e) => {
9417 tracing::error!(event = "export_task_panic", "export task panicked: {e}");
9418 (
9419 StatusCode::INTERNAL_SERVER_ERROR,
9420 Json(serde_json::json!({"error": "Internal server error"})),
9421 )
9422 .into_response()
9423 }
9424 }
9425}
9426
9427fn export_git_target() -> Option<(String, String)> {
9430 let repo = std::env::var("SLOC_EXPORT_GIT_REPO")
9431 .ok()
9432 .map(|s| s.trim().to_owned())
9433 .filter(|s| !s.is_empty())?;
9434 let branch = std::env::var("SLOC_EXPORT_GIT_BRANCH")
9435 .ok()
9436 .map(|s| s.trim().to_owned())
9437 .filter(|s| !s.is_empty())
9438 .unwrap_or_else(|| "oxide-sloc-reports".to_string());
9439 Some((repo, branch))
9440}
9441
9442async fn export_run_to_git(output_dir: PathBuf) -> Response {
9445 let Some((repo, branch)) = export_git_target() else {
9446 return (
9447 StatusCode::CONFLICT,
9448 Json(serde_json::json!({
9449 "error": "Git export is not configured. Set SLOC_EXPORT_GIT_REPO (and \
9450 optionally SLOC_EXPORT_GIT_BRANCH) and restart."
9451 })),
9452 )
9453 .into_response();
9454 };
9455 let subdir = output_dir.file_name().map_or_else(
9456 || "export".to_string(),
9457 |n| n.to_string_lossy().into_owned(),
9458 );
9459 let work_dir = std::env::temp_dir()
9460 .join("oxide-sloc-export")
9461 .join(uuid::Uuid::new_v4().to_string());
9462 let message = format!("oxide-sloc: publish {subdir}");
9463 let (repo_c, branch_c, subdir_c) = (repo.clone(), branch.clone(), subdir.clone());
9464 let src = output_dir.clone();
9465 let work_for_task = work_dir.clone();
9466 let result = tokio::task::spawn_blocking(move || {
9467 sloc_git::publish_dir(
9468 &repo_c,
9469 &branch_c,
9470 &subdir_c,
9471 &src,
9472 &message,
9473 &work_for_task,
9474 )
9475 })
9476 .await;
9477 let _ = tokio::fs::remove_dir_all(&work_dir).await; match result {
9479 Ok(Ok(())) => {
9480 audit::record(
9481 "run_exported",
9482 "success",
9483 &[("target", "git"), ("repo", &repo), ("branch", &branch)],
9484 );
9485 Json(serde_json::json!({
9486 "target": "git", "repo": repo, "branch": branch, "subdir": subdir,
9487 }))
9488 .into_response()
9489 }
9490 Ok(Err(e)) => {
9491 tracing::warn!(event = "export_error", "git export failed: {e:#}");
9492 (
9493 StatusCode::BAD_GATEWAY,
9494 Json(serde_json::json!({"error": format!("Git export failed: {e}")})),
9495 )
9496 .into_response()
9497 }
9498 Err(e) => {
9499 tracing::error!(event = "export_task_panic", "git export task panicked: {e}");
9500 (
9501 StatusCode::INTERNAL_SERVER_ERROR,
9502 Json(serde_json::json!({"error": "Internal server error"})),
9503 )
9504 .into_response()
9505 }
9506 }
9507}
9508
9509async fn export_run_handler(
9516 State(state): State<AppState>,
9517 AxumPath(run_id): AxumPath<String>,
9518 Query(query): Query<ExportQuery>,
9519) -> Response {
9520 let output_dir = resolve_run_output_dir(&state, &run_id).await;
9521 if output_dir.as_os_str().is_empty() || !output_dir.exists() {
9522 return (
9523 StatusCode::NOT_FOUND,
9524 Json(serde_json::json!({"error": "Unknown run or its artifacts are gone"})),
9525 )
9526 .into_response();
9527 }
9528 match query.target.as_deref().unwrap_or("share") {
9529 "share" => export_run_to_share(output_dir).await,
9530 "git" => export_run_to_git(output_dir).await,
9531 "confluence" => {
9532 let title = {
9535 let reg = state.registry.lock().await;
9536 reg.find_by_run_id(&run_id)
9537 .map(|e| format!("oxide-sloc — {}", e.project_label))
9538 }
9539 .unwrap_or_else(|| "oxide-sloc SLOC report".to_string());
9540 confluence::publish_run(&state, &run_id, &title, None).await
9541 }
9542 other => (
9543 StatusCode::BAD_REQUEST,
9544 Json(serde_json::json!({
9545 "error": format!(
9546 "Unsupported export target '{other}'. Supported: share, git, confluence"
9547 )
9548 })),
9549 )
9550 .into_response(),
9551 }
9552}
9553
9554async fn api_admin_config(
9562 State(state): State<AppState>,
9563 headers: axum::http::HeaderMap,
9564) -> Response {
9565 if auth::admin_key_configured() && !auth::is_admin_request(&headers) {
9566 return (
9567 StatusCode::FORBIDDEN,
9568 Json(serde_json::json!({
9569 "error": "This endpoint requires the operator admin key (SLOC_ADMIN_KEY)."
9570 })),
9571 )
9572 .into_response();
9573 }
9574 let policy_mb = {
9575 let store = state.cleanup_policy.lock().await;
9576 store.policy.as_ref().and_then(|p| p.max_total_mb)
9577 };
9578 let env_disk_mb = std::env::var("SLOC_MAX_DISK_MB")
9579 .ok()
9580 .and_then(|v| v.trim().parse::<u64>().ok());
9581 let scan_roots: Vec<String> = state
9582 .base_config
9583 .discovery
9584 .allowed_scan_roots
9585 .iter()
9586 .map(|p| p.display().to_string())
9587 .collect();
9588 Json(serde_json::json!({
9589 "server_mode": state.server_mode,
9590 "auth": {
9591 "api_keys_configured": !state.api_keys.is_empty(),
9592 "readonly_keys_configured": !state.readonly_api_keys.is_empty(),
9593 "admin_key_configured": auth::admin_key_configured(),
9594 "allow_unauthenticated": state.allow_unauthenticated,
9595 },
9596 "tls_enabled": state.tls_enabled,
9597 "disk_cap": {
9598 "env_max_disk_mb": env_disk_mb,
9599 "policy_max_total_mb": policy_mb,
9600 "effective_bytes": disk_cap_bytes(policy_mb),
9601 },
9602 "export": {
9603 "share_dir": export_share_dir().map(|p| p.display().to_string()),
9604 "auto": export_auto_enabled(),
9605 },
9606 "hostname": {
9607 "public_url": std::env::var("SLOC_PUBLIC_URL").ok().filter(|s| !s.trim().is_empty()),
9608 "allowed_hosts": effective_allowed_hosts(),
9609 },
9610 "allowed_scan_roots": scan_roots,
9611 }))
9612 .into_response()
9613}
9614
9615async fn cleanup_runs_handler(
9620 State(state): State<AppState>,
9621 Json(body): Json<serde_json::Value>,
9622) -> Response {
9623 let days = body
9624 .get("older_than_days")
9625 .and_then(serde_json::Value::as_u64)
9626 .unwrap_or(30)
9627 .max(1);
9628
9629 let cutoff = chrono::Utc::now() - chrono::Duration::days(days.cast_signed());
9630
9631 let expired: Vec<(String, PathBuf)> = {
9633 let reg = state.registry.lock().await;
9634 reg.entries
9635 .iter()
9636 .filter(|e| e.timestamp_utc < cutoff)
9637 .map(|e| {
9638 let arts = recover_artifacts_from_registry(e);
9639 (e.run_id.clone(), arts.output_dir)
9640 })
9641 .collect()
9642 };
9643
9644 let mut deleted = 0usize;
9645 for (run_id, output_dir) in &expired {
9646 state.artifacts.lock().await.remove(run_id);
9648 if output_dir.exists()
9650 && let Err(e) = tokio::fs::remove_dir_all(output_dir).await
9651 {
9652 eprintln!(
9653 "[oxide-sloc] cleanup: failed to remove {}: {e:#}",
9654 output_dir.display()
9655 );
9656 continue;
9657 }
9658 deleted += 1;
9659 }
9660
9661 let expired_ids: std::collections::HashSet<&str> =
9663 expired.iter().map(|(id, _)| id.as_str()).collect();
9664 {
9665 let mut reg = state.registry.lock().await;
9666 reg.entries
9667 .retain(|e| !expired_ids.contains(e.run_id.as_str()));
9668 let _ = reg.save(&state.registry_path);
9669 }
9670
9671 Json(serde_json::json!({ "deleted": deleted })).into_response()
9672}
9673
9674fn spawn_cleanup_policy_task(state: AppState) -> tokio::task::JoinHandle<()> {
9677 tokio::spawn(async move {
9678 loop {
9679 let interval_secs = {
9680 let store = state.cleanup_policy.lock().await;
9681 match &store.policy {
9682 Some(p) if p.enabled => u64::from(p.interval_hours.max(1)) * 3600,
9683 _ => break,
9684 }
9685 };
9686 tokio::time::sleep(Duration::from_secs(interval_secs)).await;
9687 let n = run_auto_cleanup(&state).await;
9688 tracing::info!("[cleanup-policy] scheduled pass: deleted {n} runs");
9689 }
9690 })
9691}
9692
9693fn collect_runs_to_delete(
9694 reg: &ScanRegistry,
9695 max_age_days: Option<u32>,
9696 max_run_count: Option<u32>,
9697) -> std::collections::HashSet<String> {
9698 let mut to_delete = std::collections::HashSet::new();
9699 if let Some(days) = max_age_days {
9700 let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(days));
9701 for e in ®.entries {
9702 if e.timestamp_utc < cutoff {
9703 to_delete.insert(e.run_id.clone());
9704 }
9705 }
9706 }
9707 if let Some(max_count) = max_run_count {
9708 for e in reg.entries.iter().skip(max_count as usize) {
9710 to_delete.insert(e.run_id.clone());
9711 }
9712 }
9713 to_delete
9714}
9715
9716fn select_runs_over_size_cap(
9721 sized_newest_first: &[(String, u64)],
9722 cap_bytes: u64,
9723) -> std::collections::HashSet<String> {
9724 let mut to_delete = std::collections::HashSet::new();
9725 let mut running: u64 = 0;
9726 for (run_id, size) in sized_newest_first {
9727 running = running.saturating_add(*size);
9728 if running > cap_bytes {
9729 to_delete.insert(run_id.clone());
9730 }
9731 }
9732 to_delete
9733}
9734
9735fn disk_cap_bytes(policy_max_total_mb: Option<u64>) -> Option<u64> {
9740 let env_mb = std::env::var("SLOC_MAX_DISK_MB")
9741 .ok()
9742 .and_then(|v| v.trim().parse::<u64>().ok());
9743 combine_disk_caps_mb(env_mb, policy_max_total_mb).map(|mb| mb.saturating_mul(1024 * 1024))
9744}
9745
9746fn combine_disk_caps_mb(env_mb: Option<u64>, policy_mb: Option<u64>) -> Option<u64> {
9750 match (env_mb.filter(|&mb| mb > 0), policy_mb.filter(|&mb| mb > 0)) {
9751 (Some(a), Some(b)) => Some(a.min(b)),
9752 (Some(a), None) => Some(a),
9753 (None, Some(b)) => Some(b),
9754 (None, None) => None,
9755 }
9756}
9757
9758async fn enforce_disk_cap(state: &AppState) -> u32 {
9763 let policy_mb = {
9764 let store = state.cleanup_policy.lock().await;
9765 store.policy.as_ref().and_then(|p| p.max_total_mb)
9766 };
9767 let Some(cap_bytes) = disk_cap_bytes(policy_mb) else {
9768 return 0;
9769 };
9770
9771 let dirs: Vec<(String, PathBuf)> = {
9774 let reg = state.registry.lock().await;
9775 reg.entries
9776 .iter()
9777 .map(|e| {
9778 (
9779 e.run_id.clone(),
9780 recover_artifacts_from_registry(e).output_dir,
9781 )
9782 })
9783 .collect()
9784 };
9785 if dirs.is_empty() {
9786 return 0;
9787 }
9788 let sized = tokio::task::spawn_blocking(move || {
9789 dirs.into_iter()
9790 .map(|(id, dir)| (id, dir_size_bytes(&dir)))
9791 .collect::<Vec<_>>()
9792 })
9793 .await
9794 .unwrap_or_default();
9795
9796 let to_delete = select_runs_over_size_cap(&sized, cap_bytes);
9797 if to_delete.is_empty() {
9798 return 0;
9799 }
9800 for run_id in &to_delete {
9801 delete_run_artifacts(state, run_id).await;
9802 }
9803 {
9804 let mut reg = state.registry.lock().await;
9805 reg.entries.retain(|e| !to_delete.contains(&e.run_id));
9806 let _ = reg.save(&state.registry_path);
9807 }
9808 tracing::warn!(
9809 event = "disk_cap_enforced",
9810 deleted = to_delete.len(),
9811 cap_bytes,
9812 "artifact tree exceeded disk cap; deleted oldest runs"
9813 );
9814 u32::try_from(to_delete.len()).unwrap_or(u32::MAX)
9815}
9816
9817fn spawn_disk_guard(state: AppState) {
9822 tokio::spawn(async move {
9823 let mut interval = tokio::time::interval(Duration::from_mins(10));
9824 interval.tick().await; loop {
9826 interval.tick().await;
9827 let n = enforce_disk_cap(&state).await;
9828 if n > 0 {
9829 tracing::info!("[disk-guard] enforced disk cap: deleted {n} runs");
9830 }
9831 }
9832 });
9833}
9834
9835async fn delete_run_artifacts(state: &AppState, run_id: &str) {
9836 let output_dir = {
9837 let mut cache = state.artifacts.lock().await;
9838 let d = cache.get(run_id).map(|a| a.output_dir.clone());
9839 cache.remove(run_id);
9840 d
9841 };
9842 let output_dir = if let Some(d) = output_dir {
9843 d
9844 } else {
9845 let reg = state.registry.lock().await;
9846 reg.find_by_run_id(run_id)
9847 .map(|e| recover_artifacts_from_registry(e).output_dir)
9848 .unwrap_or_default()
9849 };
9850 if output_dir.exists() {
9851 let _ = tokio::fs::remove_dir_all(&output_dir).await;
9852 }
9853}
9854
9855async fn run_auto_cleanup(state: &AppState) -> u32 {
9859 let (max_age_days, max_run_count) = {
9860 let store = state.cleanup_policy.lock().await;
9861 match &store.policy {
9862 Some(p) if p.enabled => (p.max_age_days, p.max_run_count),
9863 _ => (None, None),
9866 }
9867 };
9868
9869 let to_delete = {
9870 let reg = state.registry.lock().await;
9871 collect_runs_to_delete(®, max_age_days, max_run_count)
9872 };
9873
9874 for run_id in &to_delete {
9875 delete_run_artifacts(state, run_id).await;
9876 }
9877
9878 if !to_delete.is_empty() {
9880 let mut reg = state.registry.lock().await;
9881 reg.entries.retain(|e| !to_delete.contains(&e.run_id));
9882 let _ = reg.save(&state.registry_path);
9883 }
9884
9885 let size_deleted = enforce_disk_cap(state).await;
9888
9889 let deleted = u32::try_from(to_delete.len())
9890 .unwrap_or(u32::MAX)
9891 .saturating_add(size_deleted);
9892 {
9893 let mut store = state.cleanup_policy.lock().await;
9894 store.last_run_at = Some(chrono::Utc::now());
9895 store.last_run_deleted = Some(deleted);
9896 let _ = store.save(&state.cleanup_policy_path);
9897 }
9898 deleted
9899}
9900
9901async fn api_get_cleanup_policy(State(state): State<AppState>) -> Response {
9905 let store = state.cleanup_policy.lock().await;
9906 Json(serde_json::json!({
9907 "policy": store.policy,
9908 "last_run_at": store.last_run_at,
9909 "last_run_deleted": store.last_run_deleted,
9910 }))
9911 .into_response()
9912}
9913
9914async fn api_save_cleanup_policy(
9916 State(state): State<AppState>,
9917 Json(mut body): Json<CleanupPolicy>,
9918) -> Response {
9919 let env_mb = std::env::var("SLOC_MAX_DISK_MB")
9923 .ok()
9924 .and_then(|v| v.trim().parse::<u64>().ok());
9925 body.max_total_mb = combine_disk_caps_mb(env_mb, body.max_total_mb);
9926 {
9928 let mut handle = state.cleanup_task_handle.lock().await;
9929 if let Some(h) = handle.take() {
9930 h.abort();
9931 }
9932 }
9933 {
9934 let mut store = state.cleanup_policy.lock().await;
9935 store.policy = Some(body.clone());
9936 if let Err(e) = store.save(&state.cleanup_policy_path) {
9937 return (
9938 StatusCode::INTERNAL_SERVER_ERROR,
9939 Json(serde_json::json!({"error": e.to_string()})),
9940 )
9941 .into_response();
9942 }
9943 }
9944 if body.enabled {
9945 let handle = spawn_cleanup_policy_task(state.clone());
9946 *state.cleanup_task_handle.lock().await = Some(handle);
9947 }
9948 StatusCode::NO_CONTENT.into_response()
9949}
9950
9951async fn api_run_cleanup_now(State(state): State<AppState>) -> Response {
9953 let deleted = run_auto_cleanup(&state).await;
9954 Json(serde_json::json!({ "deleted": deleted })).into_response()
9955}
9956
9957async fn api_delete_cleanup_policy(State(state): State<AppState>) -> Response {
9959 {
9960 let mut handle = state.cleanup_task_handle.lock().await;
9961 if let Some(h) = handle.take() {
9962 h.abort();
9963 }
9964 }
9965 {
9966 let mut store = state.cleanup_policy.lock().await;
9967 store.policy = None;
9968 let _ = store.save(&state.cleanup_policy_path);
9969 }
9970 StatusCode::NO_CONTENT.into_response()
9971}
9972
9973fn swap_inline_chart_js_for_static(html: String) -> String {
9979 let Some(head_end) = html.find("</head>") else {
9980 return html;
9981 };
9982 let Some(script_start) = html[..head_end].rfind("<script") else {
9983 return html;
9984 };
9985 let Some(close_offset) = html[script_start..].find("</script>") else {
9986 return html;
9987 };
9988 let block_end = script_start + close_offset + "</script>".len();
9989 format!(
9990 "{}<script src=\"/static/chart-report.js\"></script>{}",
9991 &html[..script_start],
9992 &html[block_end..]
9993 )
9994}
9995
9996fn patch_html_nonce(html: &str, new_nonce: &str) -> String {
9998 let Some(start) = html.find("nonce=\"") else {
10000 return html
10004 .replace("<style>", &format!("<style nonce=\"{new_nonce}\">"))
10005 .replace("<script>", &format!("<script nonce=\"{new_nonce}\">"));
10006 };
10007 let value_start = start + 7; let Some(end_offset) = html[value_start..].find('"') else {
10009 return html.to_owned();
10010 };
10011 let old_nonce = &html[value_start..value_start + end_offset];
10012 html.replace(
10013 &format!("nonce=\"{old_nonce}\""),
10014 &format!("nonce=\"{new_nonce}\""),
10015 )
10016}
10017
10018fn serve_html_artifact(
10019 path: &Path,
10020 wants_download: bool,
10021 csp_nonce: &str,
10022 run_id: &str,
10023 server_mode: bool,
10024) -> Response {
10025 match fs::read_to_string(path) {
10026 Ok(raw) => {
10027 let content = patch_html_nonce(&raw, csp_nonce);
10029 if wants_download {
10030 (
10032 [
10033 (header::CONTENT_TYPE, "text/html; charset=utf-8"),
10034 (
10035 header::CONTENT_DISPOSITION,
10036 "attachment; filename=report.html",
10037 ),
10038 ],
10039 content,
10040 )
10041 .into_response()
10042 } else {
10043 Html(swap_inline_chart_js_for_static(content)).into_response()
10046 }
10047 }
10048 Err(err) if err.kind() == std::io::ErrorKind::NotFound && !run_id.is_empty() => {
10049 let filename = path.file_name().map_or_else(
10050 || "report.html".to_string(),
10051 |n| n.to_string_lossy().into_owned(),
10052 );
10053 let html = LocateFileTemplate {
10054 run_id: run_id.to_owned(),
10055 artifact_type: "html".to_string(),
10056 expected_filename: filename,
10057 server_mode,
10058 csp_nonce: csp_nonce.to_owned(),
10059 version: env!("CARGO_PKG_VERSION"),
10060 }
10061 .render()
10062 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
10063 (StatusCode::NOT_FOUND, Html(html)).into_response()
10064 }
10065 Err(err) => {
10066 let filename = path.file_name().map_or_else(
10067 || "report.html".to_string(),
10068 |n| n.to_string_lossy().into_owned(),
10069 );
10070 let msg = format!("HTML report '{filename}' could not be read.\n\nError: {err}");
10071 let html = ErrorTemplate {
10072 message: msg,
10073 last_report_url: Some("/view-reports".to_string()),
10074 last_report_label: Some("View Reports".to_string()),
10075 run_id: None,
10076 error_code: Some(404),
10077 csp_nonce: csp_nonce.to_owned(),
10078 version: env!("CARGO_PKG_VERSION"),
10079 }
10080 .render()
10081 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
10082 (StatusCode::NOT_FOUND, Html(html)).into_response()
10083 }
10084 }
10085}
10086
10087fn serve_pdf_artifact(
10089 path: &Path,
10090 report_title: &str,
10091 run_id: &str,
10092 wants_download: bool,
10093 csp_nonce: &str,
10094) -> Response {
10095 match fs::read(path) {
10096 Ok(bytes) => {
10097 let filename = build_pdf_filename(report_title, run_id);
10098 let disposition = if wants_download {
10099 format!("attachment; filename=\"{filename}\"")
10100 } else {
10101 format!("inline; filename=\"{filename}\"")
10102 };
10103 (
10104 [
10105 (header::CONTENT_TYPE, "application/pdf".to_string()),
10106 (header::CONTENT_DISPOSITION, disposition),
10107 ],
10108 bytes,
10109 )
10110 .into_response()
10111 }
10112 Err(err) => {
10113 let filename = path.file_name().map_or_else(
10114 || "report.pdf".to_string(),
10115 |n| n.to_string_lossy().into_owned(),
10116 );
10117 let msg = format!(
10118 "PDF report '{filename}' could not be read.\n\n\
10119 Error: {err}\n\n\
10120 If you moved or renamed the output folder, the stored path is now stale. \
10121 Use 'Open PDF folder' from the results page to browse the output directory."
10122 );
10123 let html = ErrorTemplate {
10124 message: msg,
10125 last_report_url: Some("/view-reports".to_string()),
10126 last_report_label: Some("View Reports".to_string()),
10127 run_id: Some(run_id.to_owned()),
10128 error_code: Some(404),
10129 csp_nonce: csp_nonce.to_owned(),
10130 version: env!("CARGO_PKG_VERSION"),
10131 }
10132 .render()
10133 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
10134 (StatusCode::NOT_FOUND, Html(html)).into_response()
10135 }
10136 }
10137}
10138
10139fn serve_json_artifact(path: &Path, wants_download: bool, csp_nonce: &str) -> Response {
10141 match fs::read(path) {
10142 Ok(bytes) => {
10143 if wants_download {
10144 (
10145 [
10146 (header::CONTENT_TYPE, "application/json; charset=utf-8"),
10147 (
10148 header::CONTENT_DISPOSITION,
10149 "attachment; filename=result.json",
10150 ),
10151 ],
10152 bytes,
10153 )
10154 .into_response()
10155 } else {
10156 (
10157 [(header::CONTENT_TYPE, "application/json; charset=utf-8")],
10158 bytes,
10159 )
10160 .into_response()
10161 }
10162 }
10163 Err(err) => {
10164 let filename = path.file_name().map_or_else(
10165 || "result.json".to_string(),
10166 |n| n.to_string_lossy().into_owned(),
10167 );
10168 let msg = format!(
10169 "JSON result '{filename}' could not be read.\n\n\
10170 Error: {err}\n\n\
10171 If you moved or renamed the output folder, the stored path is now stale. \
10172 Use 'Open JSON folder' from the results page to browse the output directory."
10173 );
10174 let html = ErrorTemplate {
10175 message: msg,
10176 last_report_url: Some("/view-reports".to_string()),
10177 last_report_label: Some("View Reports".to_string()),
10178 run_id: None,
10179 error_code: Some(404),
10180 csp_nonce: csp_nonce.to_owned(),
10181 version: env!("CARGO_PKG_VERSION"),
10182 }
10183 .render()
10184 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
10185 (StatusCode::NOT_FOUND, Html(html)).into_response()
10186 }
10187 }
10188}
10189
10190fn recover_artifacts_from_registry(entry: &RegistryEntry) -> RunArtifacts {
10192 let output_dir = entry
10195 .html_path
10196 .as_ref()
10197 .or(entry.json_path.as_ref())
10198 .or(entry.pdf_path.as_ref())
10199 .or(entry.csv_path.as_ref())
10200 .or(entry.xlsx_path.as_ref())
10201 .and_then(|p| {
10202 let parent = p.parent()?;
10203 let parent_name = parent.file_name().and_then(|n| n.to_str()).unwrap_or("");
10204 if matches!(parent_name, "html" | "json" | "pdf" | "excel") {
10206 parent.parent().map(PathBuf::from)
10207 } else {
10208 Some(parent.to_path_buf())
10209 }
10210 })
10211 .unwrap_or_default();
10212 let pdf_path = entry.pdf_path.clone().or_else(|| {
10215 let candidate = output_dir.join("report.pdf");
10216 candidate.exists().then_some(candidate)
10217 });
10218 let scan_dir_for = |ext: &str| -> Option<PathBuf> {
10222 for dir in &[output_dir.join("excel"), output_dir.clone()] {
10224 if let Some(p) = fs::read_dir(dir).ok().and_then(|entries| {
10225 entries
10226 .filter_map(std::result::Result::ok)
10227 .find(|e| {
10228 let n = e.file_name();
10229 let n = n.to_string_lossy();
10230 n.starts_with("report_") && n.ends_with(ext)
10231 })
10232 .map(|e| e.path())
10233 }) {
10234 return Some(p);
10235 }
10236 }
10237 None
10238 };
10239
10240 let csv_path = entry.csv_path.clone().or_else(|| scan_dir_for(".csv"));
10241 let xlsx_path = entry.xlsx_path.clone().or_else(|| scan_dir_for(".xlsx"));
10242 RunArtifacts {
10243 output_dir: output_dir.clone(),
10244 html_path: entry.html_path.clone(),
10245 pdf_path,
10246 json_path: entry.json_path.clone(),
10247 csv_path,
10248 xlsx_path,
10249 scan_config_path: find_scan_config_in_dir(&output_dir),
10250 report_title: entry.project_label.clone(),
10251 result_context: RunResultContext::default(),
10252 }
10253}
10254
10255#[allow(clippy::result_large_err)] async fn resolve_artifact_set(
10257 state: &AppState,
10258 run_id: &str,
10259 csp_nonce: &str,
10260) -> Result<RunArtifacts, Response> {
10261 let cached = state.artifacts.lock().await.get(run_id).cloned();
10262 if let Some(a) = cached {
10263 return Ok(a);
10264 }
10265 let reg = state.registry.lock().await;
10266 if let Some(entry) = reg.find_by_run_id(run_id) {
10267 return Ok(recover_artifacts_from_registry(entry));
10268 }
10269 drop(reg);
10270 let short_id = &run_id[..run_id.len().min(8)];
10271 let hint = if matches!(
10272 run_id,
10273 "pdf" | "html" | "json" | "csv" | "xlsx" | "scan-config"
10274 ) {
10275 format!(
10276 " The URL format appears to be reversed \u{2014} \
10277 the server expects /runs/{run_id}/{{run_id}}, not /runs/{{run_id}}/{run_id}. \
10278 Use the View Reports page to navigate to your scan."
10279 )
10280 } else {
10281 " The report may have been deleted or the report directory moved. \
10282 Use View Reports to browse your scan history."
10283 .to_string()
10284 };
10285 let error_html = ErrorTemplate {
10286 message: format!("Report not found. \"{short_id}\" is not a recognized run ID.{hint}"),
10287 last_report_url: Some("/view-reports".to_string()),
10288 last_report_label: Some("View Reports".to_string()),
10289 run_id: None,
10290 error_code: Some(404),
10291 csp_nonce: csp_nonce.to_owned(),
10292 version: env!("CARGO_PKG_VERSION"),
10293 }
10294 .render()
10295 .unwrap_or_else(|_| "<pre>Report not found.</pre>".to_string());
10296 Err((StatusCode::NOT_FOUND, Html(error_html)).into_response())
10297}
10298
10299#[allow(clippy::result_large_err)]
10306async fn resolve_or_queue_pdf(
10307 state: &AppState,
10308 pdf_path: Option<PathBuf>,
10309 json_path: Option<PathBuf>,
10310 output_dir: PathBuf,
10311 run_id: &str,
10312 report_title: &str,
10313 csp_nonce: &str,
10314) -> Result<PathBuf, Response> {
10315 if let Some(p) = pdf_path {
10316 return Ok(p);
10317 }
10318 let Some(json_src) = json_path.filter(|p| p.exists()) else {
10319 let msg = "PDF report was not generated for this run. \
10320 Re-run the analysis with PDF output enabled."
10321 .to_string();
10322 let html = ErrorTemplate {
10323 message: msg,
10324 last_report_url: Some(format!("/runs/html/{run_id}")),
10325 last_report_label: Some("View HTML Report".to_string()),
10326 run_id: Some(run_id.to_string()),
10327 error_code: Some(404),
10328 csp_nonce: csp_nonce.to_string(),
10329 version: env!("CARGO_PKG_VERSION"),
10330 }
10331 .render()
10332 .unwrap_or_else(|_| "<pre>PDF not available.</pre>".to_string());
10333 return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
10334 };
10335 let pdf_filename = build_pdf_filename(report_title, run_id);
10336 let pdf_dest = output_dir.join(&pdf_filename);
10337 if !pdf_dest.exists() {
10338 {
10340 let mut map = state.artifacts.lock().await;
10341 if let Some(entry) = map.get_mut(run_id) {
10342 entry.pdf_path = Some(pdf_dest.clone());
10343 }
10344 }
10345 {
10346 let mut reg = state.registry.lock().await;
10347 if let Some(e) = reg.entries.iter_mut().find(|e| e.run_id == run_id) {
10348 e.pdf_path = Some(pdf_dest.clone());
10349 }
10350 let _ = reg.save(&state.registry_path);
10351 }
10352 spawn_native_pdf_background(
10353 json_src,
10354 pdf_dest.clone(),
10355 run_id.to_string(),
10356 state.artifacts.clone(),
10357 );
10358 }
10359 Ok(pdf_dest)
10360}
10361
10362fn pdf_generating_response(run_id: &str, csp_nonce: &str) -> Response {
10364 let html = format!(
10365 "<!doctype html><html lang=\"en\"><head>\
10366 <meta charset=utf-8>\
10367 <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
10368 <meta http-equiv=\"refresh\" content=\"5\">\
10369 <title>OxideSLOC | Generating PDF\u{2026}</title>\
10370 <link rel=\"icon\" type=\"image/png\" href=\"/images/logo/small-logo.png\">\
10371 <style nonce=\"{csp_nonce}\">\
10372 :root{{--radius:18px;--bg:#f5efe8;--surface:rgba(255,255,255,0.86);--surface-2:#fbf7f2;\
10373 --line:#e6d0bf;--line-strong:#dcb89f;--text:#43342d;--muted:#7b675b;\
10374 --nav:#283790;--nav-2:#013e6b;--oxide-2:#b85d33;--shadow:0 18px 42px rgba(77,44,20,0.12);}}\
10375 body.dark-theme{{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;\
10376 --line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;}}\
10377 *{{box-sizing:border-box;}}html,body{{margin:0;min-height:100vh;\
10378 font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;\
10379 background:var(--bg);color:var(--text);}}\
10380 .top-nav{{position:sticky;top:0;z-index:30;\
10381 background:linear-gradient(180deg,var(--nav),var(--nav-2));\
10382 border-bottom:1px solid rgba(255,255,255,0.12);\
10383 box-shadow:0 4px 14px rgba(0,0,0,0.18);}}\
10384 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;\
10385 min-height:56px;display:flex;align-items:center;gap:14px;}}\
10386 .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}\
10387 .brand-logo{{width:42px;height:46px;object-fit:contain;flex:0 0 auto;\
10388 filter:drop-shadow(0 4px 10px rgba(0,0,0,0.22));}}\
10389 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}\
10390 .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}\
10391 .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}\
10392 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}\
10393 .nav-pill{{display:inline-flex;align-items:center;min-height:38px;padding:0 14px;\
10394 border-radius:999px;border:1px solid rgba(255,255,255,0.18);color:#fff;\
10395 background:rgba(255,255,255,0.08);font-size:12px;font-weight:700;text-decoration:none;}}\
10396 .nav-pill:hover{{background:rgba(255,255,255,0.18);}}\
10397 .theme-toggle{{width:38px;display:inline-flex;align-items:center;\
10398 justify-content:center;min-height:38px;border-radius:999px;\
10399 border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.08);cursor:pointer;}}\
10400 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}\
10401 .theme-toggle .icon-sun{{display:none;}}\
10402 body.dark-theme .theme-toggle .icon-sun{{display:block;}}\
10403 body.dark-theme .theme-toggle .icon-moon{{display:none;}}\
10404 .page{{width:100%;max-width:1720px;margin:0 auto;padding:60px 24px;\
10405 display:flex;align-items:center;justify-content:center;\
10406 min-height:calc(100vh - 56px);}}\
10407 @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}\
10408 .panel{{background:var(--surface);border:1px solid var(--line);\
10409 border-radius:var(--radius);box-shadow:var(--shadow);\
10410 padding:48px 56px;text-align:center;max-width:480px;width:100%;}}\
10411 .spin-ring{{width:56px;height:56px;border-radius:50%;\
10412 border:5px solid var(--line);border-top-color:var(--oxide-2);\
10413 animation:spin 1s linear infinite;margin:0 auto 28px;}}\
10414 @keyframes spin{{to{{transform:rotate(360deg);}}}}\
10415 h1{{margin:0 0 12px;font-size:22px;font-weight:800;color:var(--text);}}\
10416 p{{color:var(--muted);margin:0 0 28px;font-size:15px;line-height:1.5;}}\
10417 .back-link{{display:inline-flex;align-items:center;justify-content:center;\
10418 min-height:42px;padding:0 20px;border-radius:14px;\
10419 border:1px solid var(--line-strong);text-decoration:none;\
10420 color:var(--text);background:var(--surface-2);font-weight:700;font-size:14px;}}\
10421 .back-link:hover{{background:var(--line);}}\
10422 </style></head>\
10423 <body>\
10424 <div class=\"top-nav\"><div class=\"top-nav-inner\">\
10425 <a class=\"brand\" href=\"/\">\
10426 <img class=\"brand-logo\" src=\"/images/logo/small-logo.png\" alt=\"OxideSLOC logo\" />\
10427 <div class=\"brand-copy\">\
10428 <div class=\"brand-title\">OxideSLOC</div>\
10429 <div class=\"brand-subtitle\">local code analysis - metrics, history and reports</div>\
10430 </div>\
10431 </a>\
10432 <div class=\"nav-right\">\
10433 <a class=\"nav-pill\" href=\"/\">Home</a>\
10434 <a class=\"nav-pill\" href=\"/view-reports\">View Reports</a>\
10435 <a class=\"nav-pill\" href=\"/compare-scans\">Compare Scans</a>\
10436 <button type=\"button\" class=\"theme-toggle\" id=\"theme-toggle\" aria-label=\"Toggle theme\">\
10437 <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>\
10438 <svg class=\"icon-sun\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4.2\"></circle>\
10439 <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>\
10440 </button>\
10441 </div>\
10442 </div></div>\
10443 <div class=\"page\"><div class=\"panel\">\
10444 <div class=\"spin-ring\"></div>\
10445 <h1>Generating PDF\u{2026}</h1>\
10446 <p>The PDF is being generated from the scan results.<br>\
10447 This page refreshes automatically \u{2014} usually a few seconds.</p>\
10448 <a class=\"back-link\" href=\"/runs/pdf/{run_id}\">Refresh now</a>\
10449 </div></div>\
10450 <script nonce=\"{csp_nonce}\">\
10451 (function(){{\
10452 var k=\"oxide-theme\",b=document.body,s=localStorage.getItem(k);\
10453 if(s===\"dark\")b.classList.add(\"dark-theme\");\
10454 var t=document.getElementById(\"theme-toggle\");\
10455 if(t)t.addEventListener(\"click\",function(){{\
10456 var d=b.classList.toggle(\"dark-theme\");\
10457 localStorage.setItem(k,d?\"dark\":\"light\");\
10458 }});\
10459 }})();\
10460 </script>\
10461 </body></html>"
10462 );
10463 Html(html).into_response()
10464}
10465
10466fn render_error_artifact_html(
10468 message: String,
10469 last_report_url: Option<String>,
10470 last_report_label: Option<String>,
10471 run_id: Option<String>,
10472 error_code: Option<u16>,
10473 csp_nonce: &str,
10474) -> String {
10475 ErrorTemplate {
10476 message,
10477 last_report_url,
10478 last_report_label,
10479 run_id,
10480 error_code,
10481 csp_nonce: csp_nonce.to_owned(),
10482 version: env!("CARGO_PKG_VERSION"),
10483 }
10484 .render()
10485 .unwrap_or_else(|_| "<pre>Error.</pre>".to_string())
10486}
10487
10488fn serve_binary_download(path: &Path, content_type: &str, fallback_filename: &str) -> Response {
10490 fs::read(path).map_or_else(
10491 |_| StatusCode::NOT_FOUND.into_response(),
10492 |bytes| {
10493 let filename = path.file_name().map_or_else(
10494 || fallback_filename.to_string(),
10495 |n| n.to_string_lossy().into_owned(),
10496 );
10497 (
10498 [
10499 (header::CONTENT_TYPE, content_type.to_string()),
10500 (
10501 header::CONTENT_DISPOSITION,
10502 format!("attachment; filename=\"{filename}\""),
10503 ),
10504 ],
10505 bytes,
10506 )
10507 .into_response()
10508 },
10509 )
10510}
10511
10512fn serve_csv_arm(csv_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
10513 let Some(path) = csv_path else {
10514 let html = render_error_artifact_html(
10515 "CSV report was not generated for this run, or was not recorded in \
10516 the scan registry."
10517 .to_string(),
10518 Some(format!("/runs/html/{run_id}")),
10519 Some("View HTML Report".to_string()),
10520 Some(run_id.to_string()),
10521 Some(404),
10522 csp_nonce,
10523 );
10524 return (StatusCode::NOT_FOUND, Html(html)).into_response();
10525 };
10526 serve_binary_download(&path, "text/csv; charset=utf-8", "report.csv")
10527}
10528
10529fn serve_xlsx_arm(xlsx_path: Option<PathBuf>, run_id: &str, csp_nonce: &str) -> Response {
10530 let Some(path) = xlsx_path else {
10531 let html = render_error_artifact_html(
10532 "Excel report was not generated for this run, or was not recorded in \
10533 the scan registry."
10534 .to_string(),
10535 Some(format!("/runs/html/{run_id}")),
10536 Some("View HTML Report".to_string()),
10537 Some(run_id.to_string()),
10538 Some(404),
10539 csp_nonce,
10540 );
10541 return (StatusCode::NOT_FOUND, Html(html)).into_response();
10542 };
10543 serve_binary_download(
10544 &path,
10545 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
10546 "report.xlsx",
10547 )
10548}
10549
10550fn serve_scan_config_arm(artifact_set: &RunArtifacts) -> Response {
10551 let path = artifact_set
10552 .scan_config_path
10553 .as_deref()
10554 .map(std::path::Path::to_path_buf)
10555 .or_else(|| find_scan_config_in_dir(&artifact_set.output_dir))
10556 .unwrap_or_else(|| artifact_set.output_dir.join("scan-config.json"));
10557 fs::read(&path).map_or_else(
10558 |_| StatusCode::NOT_FOUND.into_response(),
10559 |bytes| {
10560 (
10561 [
10562 (
10563 header::CONTENT_TYPE,
10564 "application/json; charset=utf-8".to_string(),
10565 ),
10566 (
10567 header::CONTENT_DISPOSITION,
10568 "attachment; filename=\"scan-config.json\"".to_string(),
10569 ),
10570 ],
10571 bytes,
10572 )
10573 .into_response()
10574 },
10575 )
10576}
10577
10578async fn serve_submodule_pdf_arm(
10583 artifact: &str,
10584 artifact_set: RunArtifacts,
10585 wants_download: bool,
10586 run_id: &str,
10587 csp_nonce: &str,
10588) -> Response {
10589 let base = artifact.trim_end_matches("_pdf");
10591 let sub_dir = artifact_set.output_dir.join("submodules");
10592 let pdf_path = sub_dir.join(format!("{base}.pdf"));
10593
10594 if !pdf_path.exists() {
10595 let derived_safe = base.trim_start_matches("sub_");
10597 let rebuilt = artifact_set.json_path.as_deref().and_then(|jp| {
10598 let parent_run = read_json(jp).ok()?;
10599 let sub = parent_run
10600 .submodule_summaries
10601 .iter()
10602 .find(|s| sanitize_project_label(&s.name) == derived_safe)?
10603 .clone();
10604 let parent_path = parent_run.input_roots.first().cloned().unwrap_or_default();
10605 Some((parent_run, sub, parent_path))
10606 });
10607
10608 if let Some((parent_run, sub, parent_path)) = rebuilt {
10609 let sub_run = build_sub_run(&parent_run, &sub, &parent_path);
10610 let pp = pdf_path.clone();
10611 let _ = tokio::task::spawn_blocking(move || write_pdf_from_run(&sub_run, &pp)).await;
10612 }
10613 }
10614
10615 if !pdf_path.exists() {
10616 let html = render_error_artifact_html(
10617 "Sub-report PDF could not be generated — re-run the scan with submodule breakdown \
10618 enabled."
10619 .to_string(),
10620 Some("/view-reports".to_string()),
10621 Some("View Reports".to_string()),
10622 Some(run_id.to_string()),
10623 Some(404),
10624 csp_nonce,
10625 );
10626 return (StatusCode::NOT_FOUND, Html(html)).into_response();
10627 }
10628
10629 serve_pdf_artifact(
10630 &pdf_path,
10631 &artifact_set.report_title,
10632 run_id,
10633 wants_download,
10634 csp_nonce,
10635 )
10636}
10637
10638fn serve_submodule_arm(
10639 artifact: &str,
10640 artifact_set: &RunArtifacts,
10641 wants_download: bool,
10642 csp_nonce: &str,
10643 run_id: &str,
10644 server_mode: bool,
10645) -> Response {
10646 if artifact.len() > 128
10647 || !artifact
10648 .chars()
10649 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
10650 {
10651 return StatusCode::BAD_REQUEST.into_response();
10652 }
10653 let filename = format!("{artifact}.html");
10654 let new_layout = artifact_set.output_dir.join("submodules").join(&filename);
10656 let path = if new_layout.exists() {
10657 new_layout
10658 } else {
10659 artifact_set.output_dir.join(&filename)
10660 };
10661 if !path.exists() {
10662 let html = render_error_artifact_html(
10663 format!(
10664 "Sub-report '{artifact}' was not found in the run directory.\n\
10665 Re-run the analysis with 'Detect and separate git submodules' \
10666 and HTML output enabled."
10667 ),
10668 Some("/view-reports".to_string()),
10669 Some("View Reports".to_string()),
10670 Some(run_id.to_string()),
10671 Some(404),
10672 csp_nonce,
10673 );
10674 return (StatusCode::NOT_FOUND, Html(html)).into_response();
10675 }
10676 serve_html_artifact(&path, wants_download, csp_nonce, run_id, server_mode)
10677}
10678
10679async fn serve_pdf_arm(
10680 state: &AppState,
10681 artifact_set: RunArtifacts,
10682 wants_download: bool,
10683 run_id: &str,
10684 csp_nonce: &str,
10685) -> Response {
10686 let report_title = artifact_set.report_title.clone();
10687 let had_pdf_in_registry = artifact_set.pdf_path.is_some();
10688 let stale_html_name = artifact_set
10689 .html_path
10690 .as_deref()
10691 .and_then(|p| p.file_name())
10692 .map(|n| n.to_string_lossy().into_owned());
10693 let path = match resolve_or_queue_pdf(
10694 state,
10695 artifact_set.pdf_path,
10696 artifact_set.json_path.clone(),
10697 artifact_set.output_dir.clone(),
10698 run_id,
10699 &report_title,
10700 csp_nonce,
10701 )
10702 .await
10703 {
10704 Ok(p) => p,
10705 Err(r) => return r,
10706 };
10707 if !path.exists() {
10708 if had_pdf_in_registry && let Some(expected_filename) = stale_html_name {
10712 let html = LocateFileTemplate {
10713 run_id: run_id.to_string(),
10714 artifact_type: "pdf".to_string(),
10715 expected_filename,
10716 server_mode: state.server_mode,
10717 csp_nonce: csp_nonce.to_string(),
10718 version: env!("CARGO_PKG_VERSION"),
10719 }
10720 .render()
10721 .unwrap_or_else(|_| "<pre>File not found.</pre>".to_string());
10722 return (StatusCode::NOT_FOUND, Html(html)).into_response();
10723 }
10724 return pdf_generating_response(run_id, csp_nonce);
10725 }
10726 serve_pdf_artifact(&path, &report_title, run_id, wants_download, csp_nonce)
10727}
10728
10729async fn artifact_handler(
10730 State(state): State<AppState>,
10731 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
10732 AxumPath((artifact, run_id)): AxumPath<(String, String)>,
10733 Query(query): Query<ArtifactQuery>,
10734) -> Response {
10735 let artifact_set = match resolve_artifact_set(&state, &run_id, &csp_nonce).await {
10736 Ok(a) => a,
10737 Err(r) => return r,
10738 };
10739
10740 let wants_download = matches!(query.download.as_deref(), Some("1" | "true" | "yes"));
10741
10742 match artifact.as_str() {
10743 "html" => {
10744 let Some(path) = artifact_set.html_path else {
10745 return StatusCode::NOT_FOUND.into_response();
10746 };
10747 serve_html_artifact(
10748 &path,
10749 wants_download,
10750 &csp_nonce,
10751 &run_id,
10752 state.server_mode,
10753 )
10754 }
10755 "pdf" => serve_pdf_arm(&state, artifact_set, wants_download, &run_id, &csp_nonce).await,
10756 "json" => {
10757 let Some(path) = artifact_set.json_path else {
10758 let html = render_error_artifact_html(
10759 "JSON result was not generated for this run, or was not recorded in \
10760 the scan registry. Re-run the analysis with JSON output enabled."
10761 .to_string(),
10762 Some("/view-reports".to_string()),
10763 Some("View Reports".to_string()),
10764 Some(run_id.clone()),
10765 Some(404),
10766 &csp_nonce,
10767 );
10768 return (StatusCode::NOT_FOUND, Html(html)).into_response();
10769 };
10770 serve_json_artifact(&path, wants_download, &csp_nonce)
10771 }
10772 "csv" => serve_csv_arm(artifact_set.csv_path, &run_id, &csp_nonce),
10773 "xlsx" => serve_xlsx_arm(artifact_set.xlsx_path, &run_id, &csp_nonce),
10774 "scan-config" => serve_scan_config_arm(&artifact_set),
10775 _ if artifact.starts_with("sub_") && artifact.ends_with("_pdf") => {
10776 serve_submodule_pdf_arm(&artifact, artifact_set, wants_download, &run_id, &csp_nonce)
10777 .await
10778 }
10779 _ if artifact.starts_with("sub_") => serve_submodule_arm(
10780 &artifact,
10781 &artifact_set,
10782 wants_download,
10783 &csp_nonce,
10784 &run_id,
10785 state.server_mode,
10786 ),
10787 _ => StatusCode::NOT_FOUND.into_response(),
10788 }
10789}
10790
10791struct SubmoduleLinkRow {
10794 name: String,
10795 url: String,
10796}
10797
10798struct HistoryEntryRow {
10799 run_id: String,
10800 run_id_short: String,
10801 timestamp: String,
10802 timestamp_utc_ms: i64,
10803 project_label: String,
10804 project_path: String,
10805 files_analyzed: u64,
10806 files_skipped: u64,
10807 code_lines: u64,
10808 comment_lines: u64,
10809 blank_lines: u64,
10810 total_physical_lines: u64,
10811 functions: u64,
10812 classes: u64,
10813 variables: u64,
10814 imports: u64,
10815 test_count: u64,
10816 git_branch: String,
10817 git_commit: String,
10818 git_commit_long: String,
10820 performed_by: String,
10824 scan_os: String,
10826 has_html: bool,
10827 has_json: bool,
10828 has_pdf: bool,
10829 submodule_links: Vec<SubmoduleLinkRow>,
10830 submodule_names_csv: String,
10832}
10833
10834fn nth_weekday_of_month(
10836 year: i32,
10837 month: u32,
10838 weekday: chrono::Weekday,
10839 n: u32,
10840) -> chrono::NaiveDate {
10841 use chrono::Datelike;
10842 let mut count = 0u32;
10843 let mut day = 1u32;
10844 loop {
10845 let d = chrono::NaiveDate::from_ymd_opt(year, month, day).expect("valid date");
10846 if d.weekday() == weekday {
10847 count += 1;
10848 if count == n {
10849 return d;
10850 }
10851 }
10852 day += 1;
10853 }
10854}
10855
10856fn is_pacific_dst(dt: chrono::DateTime<chrono::Utc>) -> bool {
10860 use chrono::{Datelike, TimeZone};
10861 let year = dt.year();
10862 let dst_start = chrono::Utc.from_utc_datetime(
10863 &nth_weekday_of_month(year, 3, chrono::Weekday::Sun, 2)
10864 .and_time(chrono::NaiveTime::from_hms_opt(10, 0, 0).expect("valid")),
10865 );
10866 let dst_end = chrono::Utc.from_utc_datetime(
10867 &nth_weekday_of_month(year, 11, chrono::Weekday::Sun, 1)
10868 .and_time(chrono::NaiveTime::from_hms_opt(9, 0, 0).expect("valid")),
10869 );
10870 dt >= dst_start && dt < dst_end
10871}
10872
10873fn fmt_la_time(dt: chrono::DateTime<chrono::Utc>) -> String {
10874 if is_pacific_dst(dt) {
10875 dt.with_timezone(&chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"))
10876 .format("%Y-%m-%d %H:%M PDT")
10877 .to_string()
10878 } else {
10879 dt.with_timezone(&chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"))
10880 .format("%Y-%m-%d %H:%M PST")
10881 .to_string()
10882 }
10883}
10884
10885fn fmt_la_time_meta(dt: chrono::DateTime<chrono::Utc>) -> String {
10887 let (offset, tz) = if is_pacific_dst(dt) {
10888 (
10889 chrono::FixedOffset::west_opt(7 * 3600).expect("PDT offset valid"),
10890 "PDT",
10891 )
10892 } else {
10893 (
10894 chrono::FixedOffset::west_opt(8 * 3600).expect("PST offset valid"),
10895 "PST",
10896 )
10897 };
10898 format!(
10899 "{} {tz}",
10900 dt.with_timezone(&offset).format("%Y-%m-%d %H:%M:%S")
10901 )
10902}
10903
10904fn fmt_git_date(iso: &str) -> Option<String> {
10905 chrono::DateTime::parse_from_rfc3339(iso)
10906 .ok()
10907 .map(|d| fmt_la_time(d.with_timezone(&chrono::Utc)))
10908}
10909
10910fn extract_long_commit_from_json(path: &Path, short: &str) -> Option<String> {
10919 use std::io::{Read, Seek, SeekFrom};
10920 const TAIL: u64 = 4 * 1024 * 1024; if short.is_empty() {
10922 return None;
10923 }
10924 let len = std::fs::metadata(path).ok()?.len();
10925 let start = len.saturating_sub(TAIL);
10926 let mut file = std::fs::File::open(path).ok()?;
10927 file.seek(SeekFrom::Start(start)).ok()?;
10928 let mut buf = Vec::new();
10929 file.read_to_end(&mut buf).ok()?;
10930 let text = String::from_utf8_lossy(&buf);
10931 let short_lower = short.to_ascii_lowercase();
10932 let key = "\"git_commit_long\"";
10933 let mut found: Option<String> = None;
10934 let mut cursor = 0usize;
10935 while let Some(idx) = text[cursor..].find(key) {
10936 let after_key = cursor + idx + key.len();
10937 cursor = after_key;
10938 let rest = &text[after_key..];
10939 let Some(colon) = rest.find(':') else { break };
10940 let value_region = rest[colon + 1..].trim_start();
10941 if let Some(open) = value_region.strip_prefix('"')
10943 && let Some(close) = open.find('"')
10944 {
10945 let val = &open[..close];
10946 if val.len() >= short.len() && val.to_ascii_lowercase().starts_with(&short_lower) {
10947 found = Some(val.to_string());
10948 }
10949 }
10950 }
10951 found
10952}
10953
10954fn make_history_rows(reg: &ScanRegistry) -> Vec<HistoryEntryRow> {
10955 reg.entries
10956 .iter()
10957 .map(|e| {
10958 let submodule_links = {
10959 let mut links: Vec<SubmoduleLinkRow> = vec![];
10960 let sub_dir = e
10961 .html_path
10962 .as_ref()
10963 .and_then(|p| p.parent())
10964 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
10965 if let Some(dir) = sub_dir
10966 && let Ok(rd) = std::fs::read_dir(dir)
10967 {
10968 for entry_res in rd.flatten() {
10969 let fname = entry_res.file_name();
10970 let fname_str = fname.to_string_lossy();
10971 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
10972 let stem = &fname_str[..fname_str.len() - 5];
10973 let display = stem[4..].replace('-', " ");
10974 links.push(SubmoduleLinkRow {
10975 name: display,
10976 url: format!("/runs/{stem}/{}", e.run_id),
10977 });
10978 }
10979 }
10980 }
10981 links.sort_by(|a, b| a.name.cmp(&b.name));
10982 links
10983 };
10984 let submodule_names_csv = submodule_links
10985 .iter()
10986 .map(|l| l.name.as_str())
10987 .collect::<Vec<_>>()
10988 .join(",");
10989 HistoryEntryRow {
10990 run_id: e.run_id.clone(),
10991 run_id_short: e
10992 .run_id
10993 .split('-')
10994 .next_back()
10995 .unwrap_or(&e.run_id)
10996 .chars()
10997 .take(7)
10998 .collect(),
10999 timestamp: fmt_la_time(e.timestamp_utc),
11000 timestamp_utc_ms: e.timestamp_utc.timestamp_millis(),
11001 project_label: e.project_label.clone(),
11002 project_path: e
11003 .input_roots
11004 .first()
11005 .map(|s| sanitize_path_str(s))
11006 .unwrap_or_default(),
11007 files_analyzed: e.summary.files_analyzed,
11008 files_skipped: e.summary.files_skipped,
11009 code_lines: e.summary.code_lines,
11010 comment_lines: e.summary.comment_lines,
11011 blank_lines: e.summary.blank_lines,
11012 total_physical_lines: e.summary.total_physical_lines,
11013 functions: e.summary.functions,
11014 classes: e.summary.classes,
11015 variables: e.summary.variables,
11016 imports: e.summary.imports,
11017 test_count: e.summary.test_count,
11018 git_branch: e.git_branch.clone().unwrap_or_default(),
11019 git_commit: e.git_commit.clone().unwrap_or_default(),
11020 git_commit_long: {
11021 let short = e.git_commit.clone().unwrap_or_default();
11022 e.git_commit_long
11023 .clone()
11024 .filter(|s| !s.is_empty())
11025 .or_else(|| {
11026 e.json_path
11027 .as_ref()
11028 .and_then(|p| extract_long_commit_from_json(p, &short))
11029 })
11030 .unwrap_or(short)
11031 },
11032 performed_by: e.performed_by(),
11033 scan_os: e.scan_os.clone().unwrap_or_default(),
11034 has_html: e.html_path.as_ref().is_some_and(|p| p.exists()),
11035 has_json: e.json_path.as_ref().is_some_and(|p| p.exists()),
11036 has_pdf: e.pdf_path.as_ref().is_some_and(|p| p.exists()),
11037 submodule_links,
11038 submodule_names_csv,
11039 }
11040 })
11041 .collect()
11042}
11043
11044#[derive(Deserialize, Default)]
11045struct HistoryQuery {
11046 linked: Option<String>,
11047 error: Option<String>,
11048}
11049
11050async fn history_handler(
11051 State(state): State<AppState>,
11052 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
11053 Query(query): Query<HistoryQuery>,
11054) -> impl IntoResponse {
11055 auto_scan_watched_dirs(&state).await;
11057 let watched_dirs: Vec<String> = {
11058 let wd = state.watched_dirs.lock().await;
11059 wd.dirs.iter().map(|p| p.display().to_string()).collect()
11060 };
11061 let mut entries = {
11062 let reg = state.registry.lock().await;
11063 make_history_rows(®)
11064 };
11065 entries.retain(|e| e.has_html);
11066 let total_scans = entries.len();
11067 let linked_count = query
11068 .linked
11069 .as_deref()
11070 .and_then(|s| s.parse::<usize>().ok())
11071 .unwrap_or(0);
11072 let browse_error = query.error.filter(|s| !s.is_empty());
11073 let template = HistoryTemplate {
11074 version: env!("CARGO_PKG_VERSION"),
11075 entries,
11076 total_scans,
11077 linked_count,
11078 browse_error,
11079 watched_dirs,
11080 csp_nonce,
11081 server_mode: state.server_mode,
11082 };
11083 Html(
11084 template
11085 .render()
11086 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
11087 )
11088 .into_response()
11089}
11090
11091async fn compare_select_handler(
11092 State(state): State<AppState>,
11093 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
11094) -> impl IntoResponse {
11095 auto_scan_watched_dirs(&state).await;
11096 let watched_dirs: Vec<String> = {
11097 let wd = state.watched_dirs.lock().await;
11098 wd.dirs.iter().map(|p| p.display().to_string()).collect()
11099 };
11100 let mut entries = {
11101 let reg = state.registry.lock().await;
11102 make_history_rows(®)
11103 };
11104 entries.retain(|e| e.has_json);
11105 let total_scans = entries.len();
11106 let template = CompareSelectTemplate {
11107 version: env!("CARGO_PKG_VERSION"),
11108 entries,
11109 total_scans,
11110 watched_dirs,
11111 csp_nonce,
11112 server_mode: state.server_mode,
11113 };
11114 Html(
11115 template
11116 .render()
11117 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
11118 )
11119 .into_response()
11120}
11121
11122#[derive(Deserialize, Default)]
11125struct CompareQuery {
11126 a: Option<String>,
11127 b: Option<String>,
11128 sub: Option<String>,
11130 scope: Option<String>,
11132}
11133
11134struct CompareFileDeltaRow {
11135 relative_path: String,
11136 language: String,
11137 status: String,
11138 baseline_code: i64,
11139 current_code: i64,
11140 baseline_code_display: String,
11141 current_code_display: String,
11142 code_delta_str: String,
11143 code_delta_class: String,
11144 comment_delta_str: String,
11145 comment_delta_class: String,
11146 total_delta_str: String,
11147 total_delta_class: String,
11148}
11149
11150fn recompute_summary_from_records(run: &mut AnalysisRun) {
11153 let mut totals = SummaryTotals::default();
11154 for r in &run.per_file_records {
11155 if r.language.is_some() {
11156 totals.files_analyzed += 1;
11157 }
11158 totals.total_physical_lines += r.raw_line_categories.total_physical_lines;
11159 totals.code_lines += r.effective_counts.code_lines;
11160 totals.comment_lines += r.effective_counts.comment_lines;
11161 totals.blank_lines += r.effective_counts.blank_lines;
11162 totals.mixed_lines_separate += r.effective_counts.mixed_lines_separate;
11163 totals.functions += r.raw_line_categories.functions;
11164 totals.classes += r.raw_line_categories.classes;
11165 totals.variables += r.raw_line_categories.variables;
11166 totals.imports += r.raw_line_categories.imports;
11167 totals.test_count += r.raw_line_categories.test_count;
11168 totals.test_assertion_count += r.raw_line_categories.test_assertion_count;
11169 totals.test_suite_count += r.raw_line_categories.test_suite_count;
11170 if let Some(cov) = &r.coverage {
11171 totals.coverage_lines_found += u64::from(cov.lines_found);
11172 totals.coverage_lines_hit += u64::from(cov.lines_hit);
11173 totals.coverage_functions_found += u64::from(cov.functions_found);
11174 totals.coverage_functions_hit += u64::from(cov.functions_hit);
11175 totals.coverage_branches_found += u64::from(cov.branches_found);
11176 totals.coverage_branches_hit += u64::from(cov.branches_hit);
11177 }
11178 }
11179 totals.files_considered = totals.files_analyzed;
11180 run.summary_totals = totals;
11181}
11182
11183fn fmt_delta(n: i64) -> String {
11184 if n > 0 {
11185 format!("+{n}")
11186 } else {
11187 format!("{n}")
11188 }
11189}
11190
11191fn delta_class(n: i64) -> &'static str {
11192 use std::cmp::Ordering;
11193 match n.cmp(&0) {
11194 Ordering::Greater => "pos",
11195 Ordering::Less => "neg",
11196 Ordering::Equal => "zero",
11197 }
11198}
11199
11200#[allow(clippy::cast_precision_loss)]
11202fn fmt_pct(delta: i64, baseline: u64) -> String {
11203 if baseline == 0 {
11204 return "—".to_string();
11205 }
11206 #[allow(clippy::cast_precision_loss)]
11207 let pct = (delta as f64 / baseline as f64) * 100.0;
11208 if pct > 0.049 {
11209 format!("+{pct:.1}%")
11210 } else if pct < -0.049 {
11211 format!("{pct:.1}%")
11212 } else {
11213 "±0%".to_string()
11214 }
11215}
11216
11217fn summary_delta(curr: u64, prev: Option<u64>) -> (String, &'static str) {
11219 prev.map_or_else(
11220 || ("—".to_string(), "na"),
11221 |p| {
11222 #[allow(clippy::cast_possible_wrap)]
11223 let d = curr as i64 - p as i64;
11224 (fmt_delta(d), delta_class(d))
11225 },
11226 )
11227}
11228
11229#[allow(clippy::result_large_err)] fn load_scan_for_compare(
11231 json_path: &std::path::Path,
11232 scan_label: &str,
11233 run_id: &str,
11234 server_mode: bool,
11235 compare_url: &str,
11236 csp_nonce: &str,
11237) -> Result<sloc_core::AnalysisRun, axum::response::Response> {
11238 match read_json(json_path) {
11239 Ok(r) => Ok(r),
11240 Err(e) => {
11241 if server_mode {
11242 let html = ErrorTemplate {
11243 message: format!(
11244 "Could not load {scan_label} scan data. The scan output folder may have \
11245 been moved, renamed, or deleted. Re-running the analysis will create \
11246 fresh comparison data."
11247 ),
11248 last_report_url: Some("/compare-scans".to_string()),
11249 last_report_label: Some("Compare Scans".to_string()),
11250 run_id: Some(run_id.to_owned()),
11251 error_code: Some(404),
11252 csp_nonce: csp_nonce.to_owned(),
11253 version: env!("CARGO_PKG_VERSION"),
11254 }
11255 .render()
11256 .unwrap_or_else(|_| format!("<pre>{scan_label} load failed.</pre>"));
11257 return Err((StatusCode::NOT_FOUND, Html(html)).into_response());
11258 }
11259 let msg = format!(
11260 "Could not load {scan_label} scan data.\n\nExpected path: {}\n\nError: {e}",
11261 json_path.display()
11262 );
11263 let folder_hint = output_folder_hint(json_path);
11264 Err(missing_scan_relocate_response(
11265 &msg,
11266 run_id,
11267 &folder_hint,
11268 compare_url,
11269 false,
11270 csp_nonce,
11271 ))
11272 }
11273 }
11274}
11275
11276struct ChurnStats {
11277 new_scope: bool,
11278 scope_flag: bool,
11279 churn_rate_str: String,
11280 churn_rate_class: String,
11281}
11282
11283fn compute_churn_stats(
11284 baseline_code: u64,
11285 current_code: u64,
11286 lines_added: i64,
11287 lines_removed: i64,
11288) -> ChurnStats {
11289 let new_scope = baseline_code == 0 && current_code > 0;
11290 #[allow(clippy::cast_precision_loss)]
11291 let churn_pct = if baseline_code > 0 {
11292 (lines_added + lines_removed) as f64 / baseline_code as f64 * 100.0
11293 } else {
11294 0.0
11295 };
11296 #[allow(clippy::cast_precision_loss)]
11297 let scope_flag =
11298 new_scope || (baseline_code > 0 && lines_added as f64 / baseline_code as f64 > 0.20);
11299 let churn_rate_str = if new_scope {
11300 "New".to_string()
11301 } else if baseline_code > 0 {
11302 format!("{churn_pct:.1}%")
11303 } else {
11304 "—".to_string()
11305 };
11306 let churn_rate_class = if new_scope || churn_pct > 20.0 {
11307 "high".to_string()
11308 } else if churn_pct > 5.0 {
11309 "med".to_string()
11310 } else {
11311 "low".to_string()
11312 };
11313 ChurnStats {
11314 new_scope,
11315 scope_flag,
11316 churn_rate_str,
11317 churn_rate_class,
11318 }
11319}
11320
11321fn build_coverage_delta_card(s: &sloc_core::SummaryDelta) -> String {
11325 let has_data = s.baseline_coverage_line_pct.is_some() || s.current_coverage_line_pct.is_some();
11326 if !has_data {
11327 return String::new();
11328 }
11329 let base_str = s
11330 .baseline_coverage_line_pct
11331 .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
11332 let curr_str = s
11333 .current_coverage_line_pct
11334 .map_or_else(|| "\u{2014}".into(), |p| format!("{p:.1}%"));
11335 let (delta_str, cls) = match s.coverage_line_pct_delta {
11336 Some(d) if d > 0.0 => (format!("+{d:.1} pp"), "pos"),
11337 Some(d) if d < 0.0 => (format!("{d:.1} pp"), "neg"),
11338 Some(_) => ("\u{00b1}0.0 pp".into(), "zero"),
11339 None => ("\u{2014}".into(), "zero"),
11340 };
11341 format!(
11342 r#"<div class="delta-card">
11343 <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>
11344 <div class="delta-card-label">Line coverage</div>
11345 <div class="delta-card-from">Before: {base_str}</div>
11346 <div class="delta-card-to">{curr_str}</div>
11347 <span class="delta-card-change {cls}">{delta_str}</span>
11348 </div>"#
11349 )
11350}
11351
11352#[allow(clippy::ref_option)]
11354fn narrow_run_pair_by_scope(
11355 mut baseline: AnalysisRun,
11356 mut current: AnalysisRun,
11357 active_sub: &Option<String>,
11358 super_scope: bool,
11359) -> (AnalysisRun, AnalysisRun) {
11360 if let Some(sub_name) = active_sub {
11361 baseline
11362 .per_file_records
11363 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
11364 current
11365 .per_file_records
11366 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
11367 recompute_summary_from_records(&mut baseline);
11368 recompute_summary_from_records(&mut current);
11369 } else if super_scope {
11370 baseline.per_file_records.retain(|f| f.submodule.is_none());
11371 current.per_file_records.retain(|f| f.submodule.is_none());
11372 recompute_summary_from_records(&mut baseline);
11373 recompute_summary_from_records(&mut current);
11374 }
11375 (baseline, current)
11376}
11377
11378#[allow(clippy::ref_option)]
11380fn apply_scope_filter(runs: &mut [AnalysisRun], active_sub: &Option<String>, super_scope: bool) {
11381 if let Some(sub_name) = active_sub {
11382 for run in runs.iter_mut() {
11383 run.per_file_records
11384 .retain(|f| f.submodule.as_deref() == Some(sub_name.as_str()));
11385 recompute_summary_from_records(run);
11386 }
11387 } else if super_scope {
11388 for run in runs.iter_mut() {
11389 run.per_file_records.retain(|f| f.submodule.is_none());
11390 recompute_summary_from_records(run);
11391 }
11392 }
11393}
11394
11395#[allow(clippy::too_many_lines)]
11396async fn compare_handler(
11397 State(state): State<AppState>,
11398 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
11399 Query(query): Query<CompareQuery>,
11400) -> impl IntoResponse {
11401 let (run_id_a, run_id_b) = match (query.a.as_deref(), query.b.as_deref()) {
11404 (Some(a), Some(b)) => (a.to_string(), b.to_string()),
11405 _ => return axum::response::Redirect::to("/compare-scans").into_response(),
11406 };
11407
11408 let (maybe_a, maybe_b) = {
11409 let reg = state.registry.lock().await;
11410 (
11411 reg.find_by_run_id(&run_id_a).cloned(),
11412 reg.find_by_run_id(&run_id_b).cloned(),
11413 )
11414 };
11415
11416 let (Some(entry_a), Some(entry_b)) = (maybe_a, maybe_b) else {
11417 let html = ErrorTemplate {
11418 message: "One or both run IDs were not found in scan history. \
11419 The runs may have been deleted or the registry may have been reset."
11420 .to_string(),
11421 last_report_url: Some("/compare-scans".to_string()),
11422 last_report_label: Some("Compare Scans".to_string()),
11423 run_id: None,
11424 error_code: None,
11425 csp_nonce: csp_nonce.clone(),
11426 version: env!("CARGO_PKG_VERSION"),
11427 }
11428 .render()
11429 .unwrap_or_else(|_| "<pre>Run not found.</pre>".to_string());
11430 return Html(html).into_response();
11431 };
11432
11433 let (baseline_entry, current_entry) = if entry_a.timestamp_utc <= entry_b.timestamp_utc {
11435 (entry_a, entry_b)
11436 } else {
11437 (entry_b, entry_a)
11438 };
11439
11440 if baseline_entry.run_id != run_id_a {
11444 let canonical = format!(
11445 "/compare?a={}&b={}",
11446 baseline_entry.run_id, current_entry.run_id
11447 );
11448 return axum::response::Redirect::to(&canonical).into_response();
11449 }
11450
11451 let (Some(base_json), Some(curr_json)) = (
11452 baseline_entry.json_path.as_ref(),
11453 current_entry.json_path.as_ref(),
11454 ) else {
11455 let html = ErrorTemplate {
11456 message: "Full comparison requires JSON scan data, which was not saved for one or \
11457 both of these runs. JSON is now always saved for new scans — re-run the \
11458 affected projects to enable comparisons."
11459 .to_string(),
11460 last_report_url: Some("/compare-scans".to_string()),
11461 last_report_label: Some("Compare Scans".to_string()),
11462 run_id: None,
11463 error_code: None,
11464 csp_nonce: csp_nonce.clone(),
11465 version: env!("CARGO_PKG_VERSION"),
11466 }
11467 .render()
11468 .unwrap_or_else(|_| "<pre>JSON data missing.</pre>".to_string());
11469 return Html(html).into_response();
11470 };
11471
11472 let compare_url = format!(
11473 "/compare?a={}&b={}",
11474 baseline_entry.run_id, current_entry.run_id
11475 );
11476
11477 let baseline_run = match load_scan_for_compare(
11478 base_json,
11479 "baseline",
11480 &baseline_entry.run_id,
11481 state.server_mode,
11482 &compare_url,
11483 &csp_nonce,
11484 ) {
11485 Ok(r) => r,
11486 Err(resp) => return resp,
11487 };
11488 let current_run = match load_scan_for_compare(
11489 curr_json,
11490 "current",
11491 ¤t_entry.run_id,
11492 state.server_mode,
11493 &compare_url,
11494 &csp_nonce,
11495 ) {
11496 Ok(r) => r,
11497 Err(resp) => return resp,
11498 };
11499
11500 let active_submodule = query.sub.clone();
11501 let super_scope_active = query.scope.as_deref() == Some("super");
11502
11503 let submodule_options = baseline_run
11504 .submodule_summaries
11505 .iter()
11506 .chain(current_run.submodule_summaries.iter())
11507 .map(|s| s.name.clone())
11508 .collect::<std::collections::BTreeSet<_>>()
11509 .into_iter()
11510 .collect::<Vec<_>>();
11511 let has_any_submodule_data = !submodule_options.is_empty();
11512
11513 let (effective_baseline, effective_current) = narrow_run_pair_by_scope(
11515 baseline_run,
11516 current_run,
11517 &active_submodule,
11518 super_scope_active,
11519 );
11520
11521 let comparison = compute_delta(&effective_baseline, &effective_current);
11522
11523 let file_rows: Vec<CompareFileDeltaRow> = comparison
11524 .file_deltas
11525 .iter()
11526 .map(|d| CompareFileDeltaRow {
11527 relative_path: d.relative_path.clone(),
11528 language: d.language.clone().unwrap_or_else(|| "—".into()),
11529 status: match d.status {
11530 FileChangeStatus::Added => "added".into(),
11531 FileChangeStatus::Removed => "removed".into(),
11532 FileChangeStatus::Modified => "modified".into(),
11533 FileChangeStatus::Unchanged => "unchanged".into(),
11534 },
11535 baseline_code: d.baseline_code,
11536 current_code: d.current_code,
11537 baseline_code_display: if d.status == FileChangeStatus::Added {
11538 "—".into()
11539 } else {
11540 d.baseline_code.to_string()
11541 },
11542 current_code_display: if d.status == FileChangeStatus::Removed {
11543 "—".into()
11544 } else {
11545 d.current_code.to_string()
11546 },
11547 code_delta_str: fmt_delta(d.code_delta),
11548 code_delta_class: delta_class(d.code_delta).into(),
11549 comment_delta_str: fmt_delta(d.comment_delta),
11550 comment_delta_class: delta_class(d.comment_delta).into(),
11551 total_delta_str: fmt_delta(d.total_delta),
11552 total_delta_class: delta_class(d.total_delta).into(),
11553 })
11554 .collect();
11555
11556 let project_path = baseline_entry
11557 .input_roots
11558 .first()
11559 .map(|s| sanitize_path_str(s))
11560 .unwrap_or_default();
11561 let lines_added = sum_added_code_lines(&comparison);
11562 let lines_removed = sum_removed_code_lines(&comparison);
11563 let churn = compute_churn_stats(
11564 comparison.summary.baseline_code,
11565 comparison.summary.current_code,
11566 lines_added,
11567 lines_removed,
11568 );
11569 let s = &comparison.summary;
11570 let template = CompareTemplate {
11571 loading_overlay: loading_overlay_block(&csp_nonce, "Loading scan delta"),
11572 version: env!("CARGO_PKG_VERSION"),
11573 project_label: baseline_entry.project_label.clone(),
11574 baseline_git_commit: baseline_entry.git_commit.clone().unwrap_or_default(),
11575 current_git_commit: current_entry.git_commit.clone().unwrap_or_default(),
11576 baseline_run_id: baseline_entry.run_id.clone(),
11577 current_run_id: current_entry.run_id.clone(),
11578 baseline_run_id_short: baseline_entry
11579 .run_id
11580 .split('-')
11581 .next_back()
11582 .unwrap_or(&baseline_entry.run_id)
11583 .chars()
11584 .take(7)
11585 .collect(),
11586 current_run_id_short: current_entry
11587 .run_id
11588 .split('-')
11589 .next_back()
11590 .unwrap_or(¤t_entry.run_id)
11591 .chars()
11592 .take(7)
11593 .collect(),
11594 baseline_timestamp: fmt_la_time(baseline_entry.timestamp_utc),
11595 baseline_timestamp_utc_ms: baseline_entry.timestamp_utc.timestamp_millis(),
11596 current_timestamp: fmt_la_time(current_entry.timestamp_utc),
11597 current_timestamp_utc_ms: current_entry.timestamp_utc.timestamp_millis(),
11598 project_path: project_path.clone(),
11599 baseline_code: s.baseline_code,
11600 current_code: s.current_code,
11601 code_lines_delta_str: fmt_delta(s.code_lines_delta),
11602 code_lines_delta_class: delta_class(s.code_lines_delta).into(),
11603 baseline_files: s.baseline_files,
11604 current_files: s.current_files,
11605 files_analyzed_delta_str: fmt_delta(s.files_analyzed_delta),
11606 files_analyzed_delta_class: delta_class(s.files_analyzed_delta).into(),
11607 baseline_comments: s.baseline_comments,
11608 current_comments: s.current_comments,
11609 comment_lines_delta_str: fmt_delta(s.comment_lines_delta),
11610 comment_lines_delta_class: delta_class(s.comment_lines_delta).into(),
11611 baseline_code_fmt: fmt_comma(s.baseline_code.cast_signed()),
11612 current_code_fmt: fmt_comma(s.current_code.cast_signed()),
11613 baseline_files_fmt: fmt_comma(s.baseline_files.cast_signed()),
11614 current_files_fmt: fmt_comma(s.current_files.cast_signed()),
11615 baseline_comments_fmt: fmt_comma(s.baseline_comments.cast_signed()),
11616 current_comments_fmt: fmt_comma(s.current_comments.cast_signed()),
11617 code_lines_pct_str: fmt_pct(s.code_lines_delta, s.baseline_code),
11618 files_analyzed_pct_str: fmt_pct(s.files_analyzed_delta, s.baseline_files),
11619 comment_lines_pct_str: fmt_pct(s.comment_lines_delta, s.baseline_comments),
11620 code_lines_added: lines_added,
11621 code_lines_removed: lines_removed,
11622 code_lines_modified: sum_modified_code_lines(&comparison),
11623 code_lines_unmodified: sum_unmodified_code_lines(&comparison),
11624 code_lines_total: lines_added
11625 + lines_removed
11626 + sum_modified_code_lines(&comparison)
11627 + sum_unmodified_code_lines(&comparison),
11628 new_scope: churn.new_scope,
11629 churn_rate_str: churn.churn_rate_str,
11630 churn_rate_class: churn.churn_rate_class,
11631 scope_flag: churn.scope_flag,
11632 files_added: comparison.files_added,
11633 files_removed: comparison.files_removed,
11634 files_modified: comparison.files_modified,
11635 files_unchanged: comparison.files_unchanged,
11636 files_total: comparison.files_total,
11637 file_rows,
11638 baseline_git_author: baseline_entry.git_author.clone(),
11639 current_git_author: current_entry.git_author.clone(),
11640 baseline_git_branch: baseline_entry.git_branch.clone().unwrap_or_default(),
11641 current_git_branch: current_entry.git_branch.clone().unwrap_or_default(),
11642 baseline_performed_by: baseline_entry.performed_by(),
11643 current_performed_by: current_entry.performed_by(),
11644 baseline_git_tags: baseline_entry.git_tags.clone(),
11645 current_git_tags: current_entry.git_tags.clone(),
11646 baseline_git_commit_date: baseline_entry
11647 .git_commit_date
11648 .as_deref()
11649 .and_then(fmt_git_date),
11650 current_git_commit_date: current_entry
11651 .git_commit_date
11652 .as_deref()
11653 .and_then(fmt_git_date),
11654 project_name: project_path
11655 .rsplit(['/', '\\'])
11656 .find(|s| !s.is_empty())
11657 .unwrap_or(&project_path)
11658 .to_string(),
11659 submodule_options,
11660 has_any_submodule_data,
11661 active_submodule,
11662 super_scope_active,
11663 toast_assets: sloc_toast_assets(&csp_nonce),
11664 csp_nonce,
11665 coverage_delta_card: build_coverage_delta_card(s),
11666 baseline_test_count: effective_baseline.summary_totals.test_count,
11667 current_test_count: effective_current.summary_totals.test_count,
11668 baseline_coverage_pct: s.baseline_coverage_line_pct,
11669 current_coverage_pct: s.current_coverage_line_pct,
11670 };
11671
11672 Html(
11673 template
11674 .render()
11675 .unwrap_or_else(|e| format!("<pre>{e}</pre>")),
11676 )
11677 .into_response()
11678}
11679
11680fn format_number(n: u64) -> String {
11688 let s = n.to_string();
11689 let mut out = String::with_capacity(s.len() + s.len() / 3);
11690 let len = s.len();
11691 for (i, c) in s.chars().enumerate() {
11692 if i > 0 && (len - i).is_multiple_of(3) {
11693 out.push(',');
11694 }
11695 out.push(c);
11696 }
11697 out
11698}
11699
11700const fn badge_char_width(c: char) -> f64 {
11701 match c {
11702 'f' | 'i' | 'j' | 'l' | 'r' | 't' => 5.0,
11703 'm' | 'w' => 9.0,
11704 ' ' => 4.0,
11705 _ => 6.5,
11706 }
11707}
11708
11709#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
11710fn badge_text_px(text: &str) -> u32 {
11711 text.chars().map(badge_char_width).sum::<f64>().ceil() as u32
11712}
11713
11714fn render_badge_svg(label: &str, value: &str, color: &str) -> String {
11715 let lw = badge_text_px(label) + 20;
11716 let rw = badge_text_px(value) + 20;
11717 let total = lw + rw;
11718 let lx = lw / 2;
11719 let rx = lw + rw / 2;
11720 let le = escape_html(label);
11721 let ve = escape_html(value);
11722 let ce = escape_html(color);
11723 format!(
11724 r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20">
11725 <rect width="{total}" height="20" fill="#555"/>
11726 <rect x="{lw}" width="{rw}" height="20" fill="{ce}"/>
11727 <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
11728 <text x="{lx}" y="14" fill="#010101" fill-opacity=".3">{le}</text>
11729 <text x="{lx}" y="13">{le}</text>
11730 <text x="{rx}" y="14" fill="#010101" fill-opacity=".3">{ve}</text>
11731 <text x="{rx}" y="13">{ve}</text>
11732 </g>
11733</svg>"##
11734 )
11735}
11736
11737#[derive(Deserialize)]
11738struct BadgeQuery {
11739 label: Option<String>,
11740 color: Option<String>,
11741}
11742
11743async fn badge_handler(
11744 State(state): State<AppState>,
11745 AxumPath(metric): AxumPath<String>,
11746 Query(query): Query<BadgeQuery>,
11747) -> Response {
11748 let entry = {
11749 let reg = state.registry.lock().await;
11750 reg.entries.first().cloned()
11751 };
11752
11753 let Some(entry) = entry else {
11754 let svg = render_badge_svg("oxide-sloc", "no data", "#999");
11755 return (
11756 [
11757 (header::CONTENT_TYPE, "image/svg+xml"),
11758 (header::CACHE_CONTROL, "no-cache, max-age=0"),
11759 ],
11760 svg,
11761 )
11762 .into_response();
11763 };
11764
11765 let (default_label, value, default_color) = match metric.as_str() {
11766 "code-lines" => (
11767 "code lines",
11768 format_number(entry.summary.code_lines),
11769 "#4a78ee",
11770 ),
11771 "files" => (
11772 "files analyzed",
11773 format_number(entry.summary.files_analyzed),
11774 "#4a9862",
11775 ),
11776 "comment-lines" => (
11777 "comment lines",
11778 format_number(entry.summary.comment_lines),
11779 "#b35428",
11780 ),
11781 "blank-lines" => (
11782 "blank lines",
11783 format_number(entry.summary.blank_lines),
11784 "#7a5db0",
11785 ),
11786 _ => return StatusCode::NOT_FOUND.into_response(),
11787 };
11788
11789 let label = query.label.as_deref().unwrap_or(default_label);
11790 let color = query.color.as_deref().unwrap_or(default_color);
11791 let svg = render_badge_svg(label, &value, color);
11792
11793 (
11794 [
11795 (header::CONTENT_TYPE, "image/svg+xml"),
11796 (header::CACHE_CONTROL, "no-cache, max-age=0"),
11797 ],
11798 svg,
11799 )
11800 .into_response()
11801}
11802
11803#[derive(Serialize)]
11811struct ApiCoverageBlock {
11812 lines_found: u64,
11813 lines_hit: u64,
11814 line_pct: f64,
11815 functions_found: u64,
11816 functions_hit: u64,
11817 function_pct: f64,
11818 branches_found: u64,
11819 branches_hit: u64,
11820 branch_pct: f64,
11821}
11822
11823#[derive(Serialize)]
11824struct ApiMetricsResponse {
11825 run_id: String,
11826 timestamp: String,
11827 project: String,
11828 summary: ApiSummaryPayload,
11829 languages: Vec<ApiLanguageRow>,
11830 #[serde(skip_serializing_if = "Option::is_none")]
11831 coverage: Option<ApiCoverageBlock>,
11832}
11833
11834#[derive(Serialize)]
11835struct ApiSummaryPayload {
11836 files_analyzed: u64,
11837 files_skipped: u64,
11838 code_lines: u64,
11839 comment_lines: u64,
11840 blank_lines: u64,
11841 total_physical_lines: u64,
11842 functions: u64,
11843 classes: u64,
11844 variables: u64,
11845 imports: u64,
11846}
11847
11848#[derive(Serialize)]
11849struct ApiLanguageRow {
11850 name: String,
11851 files: u64,
11852 code_lines: u64,
11853 comment_lines: u64,
11854 blank_lines: u64,
11855 functions: u64,
11856 classes: u64,
11857 variables: u64,
11858 imports: u64,
11859}
11860
11861async fn api_metrics_latest_handler(State(state): State<AppState>) -> Response {
11862 let entry = {
11863 let reg = state.registry.lock().await;
11864 reg.entries.first().cloned()
11865 };
11866 entry.map_or_else(
11867 || error::not_found("no scans recorded yet"),
11868 |e| build_metrics_response(&e),
11869 )
11870}
11871
11872async fn api_metrics_run_handler(
11873 State(state): State<AppState>,
11874 AxumPath(run_id): AxumPath<String>,
11875) -> Response {
11876 let entry = {
11877 let reg = state.registry.lock().await;
11878 reg.find_by_run_id(&run_id).cloned()
11879 };
11880 entry.map_or_else(
11881 || error::not_found("run not found"),
11882 |e| build_metrics_response(&e),
11883 )
11884}
11885
11886fn build_metrics_response(entry: &RegistryEntry) -> Response {
11887 let languages: Vec<ApiLanguageRow> = entry
11888 .json_path
11889 .as_ref()
11890 .and_then(|p| read_json(p).ok())
11891 .map(|run| {
11892 run.totals_by_language
11893 .iter()
11894 .map(|l| ApiLanguageRow {
11895 name: l.language.display_name().to_string(),
11896 files: l.files,
11897 code_lines: l.code_lines,
11898 comment_lines: l.comment_lines,
11899 blank_lines: l.blank_lines,
11900 functions: l.functions,
11901 classes: l.classes,
11902 variables: l.variables,
11903 imports: l.imports,
11904 })
11905 .collect()
11906 })
11907 .unwrap_or_default();
11908
11909 let s = &entry.summary;
11910 let coverage = if s.coverage_lines_found > 0 {
11911 let pct = |hit: u64, found: u64| -> f64 {
11912 if found == 0 {
11913 0.0
11914 } else {
11915 #[allow(clippy::cast_precision_loss)]
11916 let v = (hit as f64 / found as f64) * 100.0;
11917 (v * 10.0).round() / 10.0
11918 }
11919 };
11920 Some(ApiCoverageBlock {
11921 lines_found: s.coverage_lines_found,
11922 lines_hit: s.coverage_lines_hit,
11923 line_pct: pct(s.coverage_lines_hit, s.coverage_lines_found),
11924 functions_found: s.coverage_functions_found,
11925 functions_hit: s.coverage_functions_hit,
11926 function_pct: pct(s.coverage_functions_hit, s.coverage_functions_found),
11927 branches_found: s.coverage_branches_found,
11928 branches_hit: s.coverage_branches_hit,
11929 branch_pct: pct(s.coverage_branches_hit, s.coverage_branches_found),
11930 })
11931 } else {
11932 None
11933 };
11934 Json(ApiMetricsResponse {
11935 run_id: entry.run_id.clone(),
11936 timestamp: entry.timestamp_utc.to_rfc3339(),
11937 project: entry.project_label.clone(),
11938 summary: ApiSummaryPayload {
11939 files_analyzed: s.files_analyzed,
11940 files_skipped: s.files_skipped,
11941 code_lines: s.code_lines,
11942 comment_lines: s.comment_lines,
11943 blank_lines: s.blank_lines,
11944 total_physical_lines: s.total_physical_lines,
11945 functions: s.functions,
11946 classes: s.classes,
11947 variables: s.variables,
11948 imports: s.imports,
11949 },
11950 languages,
11951 coverage,
11952 })
11953 .into_response()
11954}
11955
11956#[derive(Deserialize)]
11963struct ProjectHistoryQuery {
11964 path: Option<String>,
11965}
11966
11967#[derive(Serialize)]
11968struct ProjectHistoryResponse {
11969 scan_count: usize,
11970 last_scan_id: Option<String>,
11971 last_scan_timestamp: Option<String>,
11972 last_scan_code_lines: Option<u64>,
11973 last_git_branch: Option<String>,
11974 last_git_commit: Option<String>,
11975}
11976
11977fn entry_matches_project(
11980 entry: &RegistryEntry,
11981 root_str: &str,
11982 upload_root: &str,
11983 upload_name_suffix: Option<&str>,
11984) -> bool {
11985 if entry.input_roots.iter().any(|r| r == root_str) {
11986 return true;
11987 }
11988 if let Some(suffix) = upload_name_suffix {
11989 return entry
11990 .input_roots
11991 .iter()
11992 .any(|r| r.starts_with(upload_root) && r.ends_with(suffix));
11993 }
11994 false
11995}
11996
11997async fn project_history_handler(
11998 State(state): State<AppState>,
11999 Query(query): Query<ProjectHistoryQuery>,
12000) -> Response {
12001 let path = query.path.unwrap_or_default();
12002 let resolved = resolve_input_path(&path);
12003 let root_str = resolved.to_string_lossy().replace('\\', "/");
12004
12005 let upload_root = std::env::temp_dir()
12010 .join("oxide-sloc-uploads")
12011 .to_string_lossy()
12012 .replace('\\', "/");
12013 let upload_name_suffix: Option<String> =
12014 if state.server_mode && root_str.starts_with(&upload_root) {
12015 resolved
12016 .file_name()
12017 .and_then(|n| n.to_str())
12018 .map(|name| format!("/{name}"))
12019 } else {
12020 None
12021 };
12022 let suffix_ref = upload_name_suffix.as_deref();
12023
12024 let entries: Vec<_> = {
12025 let reg = state.registry.lock().await;
12026 reg.entries
12027 .iter()
12028 .filter(|e| entry_matches_project(e, &root_str, &upload_root, suffix_ref))
12029 .cloned()
12030 .collect()
12031 };
12032 let scan_count = entries.len();
12033 let last = entries.first();
12034 let last_scan_id = last.map(|e| e.run_id.clone());
12035 let last_scan_timestamp = last.map(|e| fmt_la_time(e.timestamp_utc));
12036 let last_scan_code_lines = last.map(|e| e.summary.code_lines);
12037 let last_git_branch = last.and_then(|e| e.git_branch.clone());
12038 let last_git_commit = last.and_then(|e| e.git_commit.clone());
12039
12040 Json(ProjectHistoryResponse {
12041 scan_count,
12042 last_scan_id,
12043 last_scan_timestamp,
12044 last_scan_code_lines,
12045 last_git_branch,
12046 last_git_commit,
12047 })
12048 .into_response()
12049}
12050
12051#[derive(Deserialize)]
12058struct MetricsHistoryQuery {
12059 root: Option<String>,
12060 limit: Option<usize>,
12061 submodule: Option<String>,
12064}
12065
12066#[derive(Serialize)]
12067struct MetricsSubmoduleLink {
12068 name: String,
12069 url: String,
12070}
12071
12072#[derive(Serialize)]
12073struct MetricsHistoryEntry {
12074 run_id: String,
12075 run_id_short: String,
12076 timestamp: String,
12077 commit: Option<String>,
12078 branch: Option<String>,
12079 tags: Vec<String>,
12080 nearest_tag: Option<String>,
12081 code_lines: u64,
12082 comment_lines: u64,
12083 blank_lines: u64,
12084 physical_lines: u64,
12085 files_analyzed: u64,
12086 files_skipped: u64,
12087 test_count: u64,
12088 project_label: String,
12089 html_url: Option<String>,
12090 has_pdf: bool,
12091 submodule_links: Vec<MetricsSubmoduleLink>,
12092 #[serde(skip_serializing_if = "Option::is_none")]
12094 coverage_line_pct: Option<f64>,
12095}
12096
12097fn build_entry_submodule_links(e: &sloc_core::history::RegistryEntry) -> Vec<MetricsSubmoduleLink> {
12098 let mut links: Vec<MetricsSubmoduleLink> = vec![];
12099 let sub_dir = e
12100 .html_path
12101 .as_ref()
12102 .and_then(|p| p.parent())
12103 .or_else(|| e.json_path.as_ref().and_then(|p| p.parent()));
12104 let Some(dir) = sub_dir else { return links };
12105 let Ok(rd) = std::fs::read_dir(dir) else {
12106 return links;
12107 };
12108 for entry_res in rd.flatten() {
12109 let fname = entry_res.file_name();
12110 let fname_str = fname.to_string_lossy();
12111 if fname_str.starts_with("sub_") && fname_str.ends_with(".html") {
12112 let stem = &fname_str[..fname_str.len() - 5];
12113 let display = stem[4..].replace('-', " ");
12114 links.push(MetricsSubmoduleLink {
12115 name: display,
12116 url: format!("/runs/{stem}/{}", e.run_id),
12117 });
12118 }
12119 }
12120 links.sort_by(|a, b| a.name.cmp(&b.name));
12121 links
12122}
12123
12124fn apply_submodule_filter(
12125 base: MetricsHistoryEntry,
12126 filter: &str,
12127 e: &sloc_core::history::RegistryEntry,
12128) -> Option<MetricsHistoryEntry> {
12129 let json_path = e.json_path.as_ref()?;
12130 let json_str = std::fs::read_to_string(json_path).ok()?;
12131 let run: sloc_core::AnalysisRun = serde_json::from_str(&json_str).ok()?;
12132 let sub = run
12133 .submodule_summaries
12134 .iter()
12135 .find(|s| s.name.to_lowercase() == filter || s.relative_path.to_lowercase() == filter)?;
12136 let safe = sanitize_project_label(&sub.name);
12137 let artifact_key = format!("sub_{safe}");
12138 let sub_html_url = std::path::Path::new(json_path).parent().map_or_else(
12139 || base.html_url.clone(),
12140 |run_dir| {
12141 let sub_path = run_dir.join(format!("{artifact_key}.html"));
12142 if sub_path.exists() {
12143 Some(format!("/runs/{artifact_key}/{}", e.run_id))
12144 } else {
12145 base.html_url.clone()
12146 }
12147 },
12148 );
12149
12150 let sub_files: Vec<_> = run
12153 .per_file_records
12154 .iter()
12155 .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
12156 .collect();
12157 let test_count: u64 = sub_files
12158 .iter()
12159 .map(|r| r.raw_line_categories.test_count)
12160 .sum();
12161 #[allow(clippy::cast_precision_loss)]
12162 let coverage_line_pct: Option<f64> = {
12163 let found: u64 = sub_files
12164 .iter()
12165 .filter_map(|r| r.coverage.as_ref())
12166 .map(|c| u64::from(c.lines_found))
12167 .sum();
12168 let hit: u64 = sub_files
12169 .iter()
12170 .filter_map(|r| r.coverage.as_ref())
12171 .map(|c| u64::from(c.lines_hit))
12172 .sum();
12173 if found > 0 {
12174 let pct = (hit as f64 / found as f64) * 100.0;
12175 Some((pct * 10.0).round() / 10.0)
12176 } else {
12177 None
12178 }
12179 };
12180
12181 Some(MetricsHistoryEntry {
12182 code_lines: sub.code_lines,
12183 comment_lines: sub.comment_lines,
12184 blank_lines: sub.blank_lines,
12185 physical_lines: sub.total_physical_lines,
12186 files_analyzed: sub.files_analyzed,
12187 files_skipped: 0,
12188 test_count,
12189 html_url: sub_html_url,
12190 has_pdf: false,
12191 submodule_links: vec![],
12192 coverage_line_pct,
12193 ..base
12194 })
12195}
12196
12197#[allow(clippy::too_many_lines)] async fn api_metrics_history_handler(
12199 State(state): State<AppState>,
12200 Query(query): Query<MetricsHistoryQuery>,
12201) -> Response {
12202 let limit = query.limit.unwrap_or(50).min(500);
12203 let submodule_filter = query.submodule.as_deref().map(str::to_lowercase);
12204
12205 let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
12206 let reg = state.registry.lock().await;
12207 reg.entries
12208 .iter()
12209 .filter(|e| {
12210 query.root.as_ref().is_none_or(|root| {
12211 let resolved = resolve_input_path(root);
12212 let root_str = resolved.to_string_lossy().replace('\\', "/");
12213 e.input_roots.iter().any(|r| r == &root_str)
12214 })
12215 })
12216 .take(limit)
12217 .cloned()
12218 .collect()
12219 };
12220
12221 let entries: Vec<MetricsHistoryEntry> = candidate_entries
12222 .into_iter()
12223 .filter_map(|e| {
12224 let tags = e
12225 .git_tags
12226 .as_deref()
12227 .map(|s| {
12228 s.split(',')
12229 .map(|t| t.trim().to_string())
12230 .filter(|t| !t.is_empty())
12231 .collect()
12232 })
12233 .unwrap_or_default();
12234 let html_url = e
12235 .html_path
12236 .as_ref()
12237 .filter(|p| p.exists())
12238 .map(|_| format!("/runs/html/{}", e.run_id));
12239 let nearest_tag = e.git_nearest_tag.clone();
12240 let has_pdf = e.pdf_path.as_ref().is_some_and(|p| p.exists());
12241 let run_id_short: String = e
12242 .run_id
12243 .split('-')
12244 .next_back()
12245 .unwrap_or(&e.run_id)
12246 .chars()
12247 .take(7)
12248 .collect();
12249 let submodule_links = build_entry_submodule_links(&e);
12250 #[allow(clippy::cast_precision_loss)]
12251 let coverage_line_pct = if e.summary.coverage_lines_found > 0 {
12252 let pct = (e.summary.coverage_lines_hit as f64
12253 / e.summary.coverage_lines_found as f64)
12254 * 100.0;
12255 Some((pct * 10.0).round() / 10.0)
12256 } else {
12257 None
12258 };
12259 let base = MetricsHistoryEntry {
12260 run_id: e.run_id.clone(),
12261 run_id_short,
12262 timestamp: e.timestamp_utc.to_rfc3339(),
12263 commit: e.git_commit.clone(),
12264 branch: e.git_branch.clone(),
12265 tags,
12266 nearest_tag,
12267 code_lines: e.summary.code_lines,
12268 comment_lines: e.summary.comment_lines,
12269 blank_lines: e.summary.blank_lines,
12270 physical_lines: e.summary.total_physical_lines,
12271 files_analyzed: e.summary.files_analyzed,
12272 files_skipped: e.summary.files_skipped,
12273 test_count: e.summary.test_count,
12274 project_label: e.project_label.clone(),
12275 html_url,
12276 has_pdf,
12277 submodule_links,
12278 coverage_line_pct,
12279 };
12280 if let Some(ref filter) = submodule_filter {
12281 apply_submodule_filter(base, filter, &e)
12282 } else {
12283 Some(base)
12284 }
12285 })
12286 .collect();
12287
12288 Json(entries).into_response()
12289}
12290
12291#[derive(Serialize)]
12293struct ChurnEntry {
12294 run_id: String,
12295 added: i64,
12296 removed: i64,
12297 modified: i64,
12298 unmodified: i64,
12299}
12300
12301async fn api_metrics_churn_handler(
12306 State(state): State<AppState>,
12307 Query(query): Query<MetricsHistoryQuery>,
12308) -> Response {
12309 let limit = query.limit.unwrap_or(200).min(500);
12310 let candidate_entries: Vec<sloc_core::history::RegistryEntry> = {
12311 let reg = state.registry.lock().await;
12312 reg.entries
12313 .iter()
12314 .filter(|e| {
12315 query.root.as_ref().is_none_or(|root| {
12316 let resolved = resolve_input_path(root);
12317 let root_str = resolved.to_string_lossy().replace('\\', "/");
12318 e.input_roots.iter().any(|r| r == &root_str)
12319 })
12320 })
12321 .take(limit)
12322 .cloned()
12323 .collect()
12324 };
12325 let mut by_project: std::collections::HashMap<String, Vec<sloc_core::history::RegistryEntry>> =
12326 std::collections::HashMap::new();
12327 for e in candidate_entries {
12328 by_project
12329 .entry(e.project_label.clone())
12330 .or_default()
12331 .push(e);
12332 }
12333 let mut out: Vec<ChurnEntry> = Vec::new();
12334 for (_proj, mut entries) in by_project {
12335 entries.sort_by_key(|e| e.timestamp_utc);
12336 let mut prev_run: Option<sloc_core::AnalysisRun> = None;
12337 for e in &entries {
12338 let curr = e
12339 .json_path
12340 .as_ref()
12341 .and_then(|path| sloc_core::read_json(path).ok());
12342 if let (Some(prev), Some(cur)) = (prev_run.as_ref(), curr.as_ref()) {
12343 let cmp = sloc_core::compute_delta(prev, cur);
12344 out.push(ChurnEntry {
12345 run_id: e.run_id.clone(),
12346 added: sum_added_code_lines(&cmp),
12347 removed: sum_removed_code_lines(&cmp),
12348 modified: sum_modified_code_lines(&cmp),
12349 unmodified: sum_unmodified_code_lines(&cmp),
12350 });
12351 } else {
12352 out.push(ChurnEntry {
12353 run_id: e.run_id.clone(),
12354 added: 0,
12355 removed: 0,
12356 modified: 0,
12357 unmodified: 0,
12358 });
12359 }
12360 if curr.is_some() {
12361 prev_run = curr;
12362 }
12363 }
12364 }
12365 Json(out).into_response()
12366}
12367
12368#[derive(Deserialize)]
12372struct MetricsSubmodulesQuery {
12373 root: Option<String>,
12374}
12375
12376#[derive(Serialize)]
12377struct SubmoduleEntry {
12378 name: String,
12379 relative_path: String,
12380}
12381
12382async fn api_metrics_submodules_handler(
12383 State(state): State<AppState>,
12384 Query(query): Query<MetricsSubmodulesQuery>,
12385) -> Response {
12386 let json_paths: Vec<std::path::PathBuf> = {
12387 let reg = state.registry.lock().await;
12388 reg.entries
12389 .iter()
12390 .filter(|e| {
12391 query.root.as_ref().is_none_or(|root| {
12392 let resolved = resolve_input_path(root);
12393 let root_str = resolved.to_string_lossy().replace('\\', "/");
12394 e.input_roots.iter().any(|r| r == &root_str)
12395 })
12396 })
12397 .filter_map(|e| e.json_path.clone())
12398 .collect()
12399 };
12400
12401 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
12402 let mut result: Vec<SubmoduleEntry> = Vec::new();
12403
12404 for path in &json_paths {
12405 let Ok(json_str) = tokio::fs::read_to_string(path).await else {
12406 continue;
12407 };
12408 let Ok(run): Result<sloc_core::AnalysisRun, _> = serde_json::from_str(&json_str) else {
12409 continue;
12410 };
12411 for sub in &run.submodule_summaries {
12412 if seen.insert(sub.name.clone()) {
12413 result.push(SubmoduleEntry {
12414 name: sub.name.clone(),
12415 relative_path: sub.relative_path.clone(),
12416 });
12417 }
12418 }
12419 }
12420
12421 result.sort_by(|a, b| a.name.cmp(&b.name));
12422 Json(result).into_response()
12423}
12424
12425#[derive(Deserialize)]
12434struct IngestQuery {
12435 label: Option<String>,
12436}
12437
12438#[derive(Serialize)]
12439struct IngestResponse {
12440 run_id: String,
12441 view_url: String,
12442}
12443
12444async fn api_ingest_handler(
12445 State(state): State<AppState>,
12446 Query(q): Query<IngestQuery>,
12447 Json(run): Json<sloc_core::AnalysisRun>,
12448) -> Response {
12449 let label = q.label.unwrap_or_else(|| {
12450 run.input_roots
12451 .first()
12452 .map_or_else(|| "ingested".to_owned(), |r| sanitize_project_label(r))
12453 });
12454
12455 let label_for_task = label.clone();
12456 let result = tokio::task::spawn_blocking(move || {
12457 let html = render_html(&run)?;
12458 let run_id = run.tool.run_id.clone();
12459 let run_id_safe = run_id.len() <= 128
12460 && !run_id.is_empty()
12461 && run_id
12462 .chars()
12463 .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'));
12464 if !run_id_safe {
12465 anyhow::bail!(
12466 "invalid run_id: must be 1-128 alphanumeric/dash/underscore/dot characters"
12467 );
12468 }
12469 let project_label = sanitize_project_label(&label_for_task);
12470 let output_dir = resolve_output_root(None).join(derive_run_dir_name(
12471 &project_label,
12472 run.git_branch.as_deref(),
12473 &run_id,
12474 ));
12475 let file_stem = match run.git_commit_short.as_deref().map(str::trim) {
12476 Some(c) if !c.is_empty() => format!("{project_label}_{c}"),
12477 _ => project_label,
12478 };
12479 let (artifacts, _pending_pdf) = persist_run_artifacts(
12480 &run,
12481 &html,
12482 &output_dir,
12483 &label_for_task,
12484 &file_stem,
12485 RunResultContext::default(),
12486 )?;
12487 Ok::<_, anyhow::Error>((run_id, artifacts, run))
12488 })
12489 .await;
12490
12491 match result {
12492 Ok(Ok((run_id, artifacts, run))) => {
12493 register_artifacts_in_registry(&state, &label, &run, &artifacts).await;
12494 (
12495 StatusCode::CREATED,
12496 Json(IngestResponse {
12497 view_url: format!("/view-reports?run_id={run_id}"),
12498 run_id,
12499 }),
12500 )
12501 .into_response()
12502 }
12503 Ok(Err(e)) => error::internal(&format!("{e:#}")),
12504 Err(e) => error::internal(&format!("{e}")),
12505 }
12506}
12507
12508fn html_escape(s: &str) -> String {
12512 s.replace('&', "&")
12513 .replace('<', "<")
12514 .replace('>', ">")
12515 .replace('"', """)
12516}
12517
12518#[allow(clippy::cast_precision_loss)]
12519fn fmt_num(n: i64) -> String {
12520 let a = n.unsigned_abs();
12521 if a >= 1_000_000 {
12522 let v = n as f64 / 1_000_000.0;
12523 let s = format!("{v:.1}");
12524 format!("{}M", s.trim_end_matches(".0"))
12525 } else if a >= 10_000 {
12526 let v = n as f64 / 1_000.0;
12527 let s = format!("{v:.1}");
12528 format!("{}K", s.trim_end_matches(".0"))
12529 } else {
12530 let sign = if n < 0 { "-" } else { "" };
12531 if a < 1_000 {
12532 return format!("{sign}{a}");
12533 }
12534 format!("{sign}{},{:03}", a / 1_000, a % 1_000)
12535 }
12536}
12537
12538fn fmt_comma(n: i64) -> String {
12539 let sign = if n < 0 { "-" } else { "" };
12540 let a = n.unsigned_abs();
12541 if a < 1_000 {
12542 return format!("{sign}{a}");
12543 }
12544 let s = a.to_string();
12545 let bytes = s.as_bytes();
12546 let len = bytes.len();
12547 let mut out = String::with_capacity(len + len / 3);
12548 for (i, &b) in bytes.iter().enumerate() {
12549 if i > 0 && (len - i).is_multiple_of(3) {
12550 out.push(',');
12551 }
12552 out.push(b as char);
12553 }
12554 format!("{sign}{out}")
12555}
12556
12557fn group_thousands(s: &str) -> String {
12564 let (sign, rest) = match s.as_bytes().first() {
12565 Some(b'-') => ("-", &s[1..]),
12566 Some(b'+') => ("+", &s[1..]),
12567 _ => ("", s),
12568 };
12569 let (int_part, frac_part) = match rest.split_once('.') {
12570 Some((i, f)) => (i, Some(f)),
12571 None => (rest, None),
12572 };
12573 if int_part.is_empty() || !int_part.bytes().all(|b| b.is_ascii_digit()) {
12574 return s.to_string();
12575 }
12576 let bytes = int_part.as_bytes();
12577 let len = bytes.len();
12578 let mut grouped = String::with_capacity(len + len / 3);
12579 for (i, &b) in bytes.iter().enumerate() {
12580 if i > 0 && (len - i).is_multiple_of(3) {
12581 grouped.push(',');
12582 }
12583 grouped.push(b as char);
12584 }
12585 frac_part.map_or_else(
12586 || format!("{sign}{grouped}"),
12587 |f| format!("{sign}{grouped}.{f}"),
12588 )
12589}
12590
12591mod filters {
12593 #![allow(clippy::inline_always, clippy::unused_self, clippy::unnecessary_wraps)]
12596 use askama::{Result, Values};
12597
12598 #[askama::filter_fn]
12603 pub fn commas<T: core::fmt::Display>(value: T, _: &dyn Values) -> Result<String> {
12604 Ok(super::group_thousands(&value.to_string()))
12605 }
12606}
12607
12608#[derive(Deserialize, Default)]
12609struct MultiCompareQuery {
12610 runs: Option<String>,
12611 scope: Option<String>,
12613 sub: Option<String>,
12615}
12616
12617#[allow(clippy::too_many_lines)]
12618async fn multi_compare_handler(
12619 State(state): State<AppState>,
12620 Query(params): Query<MultiCompareQuery>,
12621 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
12622) -> impl IntoResponse {
12623 let run_ids: Vec<String> = params
12624 .runs
12625 .as_deref()
12626 .unwrap_or("")
12627 .split(',')
12628 .map(|s| s.trim().to_string())
12629 .filter(|s| !s.is_empty())
12630 .collect();
12631
12632 if run_ids.len() < 2 {
12633 return Html(
12634 "<p style='font-family:sans-serif;padding:2rem'>At least 2 run IDs are required. \
12635 <a href=\"/compare-scans\">Go back</a></p>",
12636 )
12637 .into_response();
12638 }
12639 if run_ids.len() > 20 {
12640 return Html(
12641 "<p style='font-family:sans-serif;padding:2rem'>At most 20 scans can be compared \
12642 at once. <a href=\"/compare-scans\">Go back</a></p>",
12643 )
12644 .into_response();
12645 }
12646
12647 let entries: Vec<Option<RegistryEntry>> = {
12649 let reg = state.registry.lock().await;
12650 run_ids
12651 .iter()
12652 .map(|id| reg.entries.iter().find(|e| &e.run_id == id).cloned())
12653 .collect()
12654 };
12655
12656 for (i, entry) in entries.iter().enumerate() {
12657 if entry.is_none() {
12658 let html = format!(
12659 "<p style='font-family:sans-serif;padding:2rem'>Scan ID <code>{}</code> not \
12660 found. <a href=\"/compare-scans\">Go back</a></p>",
12661 run_ids[i]
12662 );
12663 return Html(html).into_response();
12664 }
12665 }
12666
12667 let mut entries: Vec<RegistryEntry> = entries.into_iter().flatten().collect();
12668
12669 for entry in &entries {
12670 if entry.json_path.is_none() {
12671 let html = format!(
12672 "<p style='font-family:sans-serif;padding:2rem'>Scan <code>{}</code> has no \
12673 JSON data — re-run the analysis to enable comparison. \
12674 <a href=\"/compare-scans\">Go back</a></p>",
12675 entry.run_id
12676 );
12677 return Html(html).into_response();
12678 }
12679 }
12680
12681 entries.sort_by_key(|e| e.timestamp_utc);
12683
12684 let mut runs: Vec<AnalysisRun> = Vec::with_capacity(entries.len());
12686 for entry in &entries {
12687 let path = entry.json_path.as_ref().unwrap();
12688 match read_json(path) {
12689 Ok(r) => runs.push(r),
12690 Err(e) => {
12691 let html = format!(
12692 "<p style='font-family:sans-serif;padding:2rem'>Could not load scan \
12693 <code>{}</code>: {e}. <a href=\"/compare-scans\">Go back</a></p>",
12694 entry.run_id
12695 );
12696 return Html(html).into_response();
12697 }
12698 }
12699 }
12700
12701 let all_sub_names: Vec<String> = {
12703 let mut set = std::collections::BTreeSet::new();
12704 for r in &runs {
12705 for s in &r.submodule_summaries {
12706 set.insert(s.name.clone());
12707 }
12708 }
12709 set.into_iter().collect()
12710 };
12711 let has_submodule_data = !all_sub_names.is_empty();
12712 let active_submodule = params.sub.clone();
12713 let super_scope_active = params.scope.as_deref() == Some("super");
12714
12715 apply_scope_filter(&mut runs, &active_submodule, super_scope_active);
12717
12718 let runs_csv = params.runs.as_deref().unwrap_or("").to_string();
12719 let project_label = entries
12720 .first()
12721 .map_or("", |e| e.project_label.as_str())
12722 .to_string();
12723 let run_refs: Vec<&AnalysisRun> = runs.iter().collect();
12724 let multi = compute_multi_delta(&run_refs);
12725 let html = multi_compare_page(
12726 &multi,
12727 &project_label,
12728 env!("CARGO_PKG_VERSION"),
12729 &csp_nonce,
12730 has_submodule_data,
12731 &all_sub_names,
12732 &runs_csv,
12733 super_scope_active,
12734 active_submodule.as_deref(),
12735 &entries,
12736 );
12737 (
12740 [(axum::http::header::CACHE_CONTROL, "no-store")],
12741 Html(html),
12742 )
12743 .into_response()
12744}
12745
12746const fn multi_delta_class(n: i64) -> &'static str {
12747 match n {
12748 1.. => "pos",
12749 ..=-1 => "neg",
12750 0 => "zero",
12751 }
12752}
12753
12754fn multi_fmt_delta(n: i64) -> String {
12755 if n > 0 {
12756 format!("+{n}")
12757 } else {
12758 format!("{n}")
12759 }
12760}
12761
12762fn js_escape(s: &str) -> String {
12764 use std::fmt::Write as _;
12765 let mut out = String::with_capacity(s.len() + 2);
12766 for c in s.chars() {
12767 match c {
12768 '"' => out.push_str("\\\""),
12769 '\\' => out.push_str("\\\\"),
12770 '\n' => out.push_str("\\n"),
12771 '\r' => out.push_str("\\r"),
12772 '\t' => out.push_str("\\t"),
12773 c if (c as u32) < 0x20 => {
12774 let _ = write!(out, "\\u{:04x}", c as u32);
12775 }
12776 c => out.push(c),
12777 }
12778 }
12779 out
12780}
12781
12782fn mc_entry_html_data(entries: &[RegistryEntry], idx: usize, run_id: &str) -> (String, String) {
12784 let Some(entry) = entries.get(idx).filter(|e| e.run_id == run_id) else {
12785 return (
12786 "—".to_string(),
12787 "<span class=\"mc-row-val\">—</span>".to_string(),
12788 );
12789 };
12790 let cd = entry
12791 .git_commit_date
12792 .as_deref()
12793 .and_then(fmt_git_date)
12794 .unwrap_or_else(|| "—".to_string());
12795 let au = entry.git_author.as_deref().map_or_else(
12796 || "<span class=\"mc-row-val\">—</span>".to_string(),
12797 |a| {
12798 format!(
12799 "<span class=\"mc-row-val\"><span class=\"cmp-author-val\">{}</span>\
12800 <span class=\"cmp-author-handle\"></span></span>",
12801 html_escape(a)
12802 )
12803 },
12804 );
12805 (cd, au)
12806}
12807
12808fn mc_scope_badge(active_sub: Option<&str>, super_scope_active: bool) -> String {
12810 active_sub.map_or_else(
12811 || {
12812 if super_scope_active {
12813 "<span class=\"mc-scope-tag mc-scope-super\">Super-repo only</span>".to_string()
12814 } else {
12815 "<span class=\"mc-scope-tag mc-scope-full\">\
12816 <svg width=\"9\" height=\"9\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.2\">\
12817 <circle cx=\"12\" cy=\"12\" r=\"10\"></circle>\
12818 <line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"></line>\
12819 <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>\
12820 </svg> Full scan</span>"
12821 .to_string()
12822 }
12823 },
12824 |s| format!("<span class=\"mc-scope-tag mc-scope-sub\">{}</span>", html_escape(s)),
12825 )
12826}
12827
12828fn build_mc_scan_strip(
12830 multi: &MultiScanComparison,
12831 entries: &[RegistryEntry],
12832 n: usize,
12833 is_many: bool,
12834 active_sub: Option<&str>,
12835 super_scope_active: bool,
12836 project_label: &str,
12837) -> String {
12838 use std::fmt::Write as _;
12839 let mut scan_strip = String::new();
12840 for (i, pt) in multi.points.iter().enumerate() {
12841 let ts_ms = pt.timestamp.timestamp_millis();
12842 let ts = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
12843 let commit = pt.git_commit.as_deref().unwrap_or("\u{2014}");
12844 let branch = pt.git_branch.as_deref().unwrap_or("");
12845 let report_link = format!("/runs/html/{}", pt.run_id);
12846 let branch_html = if branch.is_empty() {
12847 "<span class=\"mc-row-val\">—</span>".to_string()
12848 } else {
12849 format!(
12850 "<span class=\"mc-card-branch\">{}</span>",
12851 html_escape(branch)
12852 )
12853 };
12854 let (commit_date_html, author_html) = mc_entry_html_data(entries, i, &pt.run_id);
12855 let tags_html = pt
12856 .git_tags
12857 .as_deref()
12858 .filter(|t| !t.is_empty())
12859 .map(|t| {
12860 let chips = t
12861 .split(',')
12862 .filter(|s| !s.is_empty())
12863 .map(|tag| format!("<span class='mc-tag'>{}</span>", html_escape(tag)))
12864 .collect::<Vec<_>>()
12865 .join(" ");
12866 format!(
12867 "<div class=\"mc-card-row\"><span class=\"mc-row-label\">Tags:</span>\
12868 <span class=\"mc-row-val\">{chips}</span></div>"
12869 )
12870 })
12871 .unwrap_or_default();
12872 let nearest = pt
12873 .git_nearest_tag
12874 .as_deref()
12875 .map(|t| format!("near {}", html_escape(t)))
12876 .unwrap_or_default();
12877 let arrow = if i < n - 1 && !is_many {
12878 "<div class='mc-arrow'>→</div>"
12879 } else {
12880 ""
12881 };
12882 let scope_badge = mc_scope_badge(active_sub, super_scope_active);
12883 let nearest_html = if nearest.is_empty() {
12884 String::new()
12885 } else {
12886 format!(
12887 "<span class=\"mc-card-nearest-wrap\">\
12888 <span class=\"mc-card-nearest\">{nearest}</span>\
12889 <span class=\"mc-card-nearest-tip\">Nearest ancestor git release tag at scan time</span>\
12890 </span>"
12891 )
12892 };
12893 write!(
12894 scan_strip,
12895 r#"<div class="mc-card">
12896 <div class="mc-card-header">
12897 <div class="mc-card-num">Scan {num}</div>
12898 <div class="mc-card-project-col">
12899 <div class="mc-card-project">{project_label}</div>
12900 {scope_badge}
12901 </div>
12902 </div>
12903 <a class="mc-card-commit" href="{report_link}" target="_blank" title="View report">{commit}</a>
12904 <div class="mc-card-rows">
12905 <div class="mc-card-row"><span class="mc-row-label">Branch:</span>{branch_html}</div>
12906 <div class="mc-card-row"><span class="mc-row-label">Last commit on:</span><span class="mc-row-val">{commit_date}</span></div>
12907 <div class="mc-card-row"><span class="mc-row-label">Last commit by:</span>{author_html}</div>
12908 <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>
12909 {tags_html}
12910 </div>
12911 <div class="mc-card-code"><strong>{code} loc</strong>{nearest_html}</div>
12912 </div>{arrow}"#,
12913 num = i + 1,
12914 commit = html_escape(commit),
12915 commit_date = commit_date_html,
12916 ts_ms = ts_ms,
12917 code = fmt_num(pt.code_lines),
12918 scope_badge = scope_badge,
12919 nearest_html = nearest_html,
12920 )
12921 .unwrap();
12922 }
12923 scan_strip
12924}
12925
12926#[allow(clippy::too_many_lines)]
12928fn build_mc_metrics_table(multi: &MultiScanComparison, n: usize) -> (String, String) {
12929 use std::fmt::Write as _;
12930 struct MetricRow<'a> {
12931 label: &'a str,
12932 values: Vec<i64>,
12933 seq_deltas: Vec<i64>,
12934 net_delta: i64,
12935 }
12936 let rows: Vec<MetricRow<'_>> = vec![
12937 MetricRow {
12938 label: "Code Lines",
12939 values: multi.points.iter().map(|p| p.code_lines).collect(),
12940 seq_deltas: multi
12941 .sequential_deltas
12942 .iter()
12943 .map(|d| d.summary.code_lines_delta)
12944 .collect(),
12945 net_delta: multi.total_delta.code_lines_delta,
12946 },
12947 MetricRow {
12948 label: "Files Analyzed",
12949 values: multi.points.iter().map(|p| p.files_analyzed).collect(),
12950 seq_deltas: multi
12951 .sequential_deltas
12952 .iter()
12953 .map(|d| d.summary.files_analyzed_delta)
12954 .collect(),
12955 net_delta: multi.total_delta.files_analyzed_delta,
12956 },
12957 MetricRow {
12958 label: "Comment Lines",
12959 values: multi.points.iter().map(|p| p.comment_lines).collect(),
12960 seq_deltas: multi
12961 .sequential_deltas
12962 .iter()
12963 .map(|d| d.summary.comment_lines_delta)
12964 .collect(),
12965 net_delta: multi.total_delta.comment_lines_delta,
12966 },
12967 MetricRow {
12968 label: "Blank Lines",
12969 values: multi.points.iter().map(|p| p.blank_lines).collect(),
12970 seq_deltas: multi
12971 .sequential_deltas
12972 .iter()
12973 .map(|d| d.summary.blank_lines_delta)
12974 .collect(),
12975 net_delta: multi.total_delta.blank_lines_delta,
12976 },
12977 MetricRow {
12978 label: "Tests",
12979 values: multi.points.iter().map(|p| p.test_count).collect(),
12980 seq_deltas: multi
12981 .points
12982 .windows(2)
12983 .map(|pts| pts[1].test_count - pts[0].test_count)
12984 .collect(),
12985 net_delta: multi.points.last().map_or(0, |l| l.test_count)
12986 - multi.points.first().map_or(0, |f| f.test_count),
12987 },
12988 ];
12989 let mut metrics_thead = String::from("<tr><th class='mc-met-label'>Metric</th>");
12990 for i in 0..n {
12991 write!(metrics_thead, "<th class='mc-val-col'>Scan {}</th>", i + 1).unwrap();
12992 if i < n - 1 {
12993 metrics_thead.push_str("<th class='mc-delta-col'>→Δ</th>");
12994 }
12995 }
12996 metrics_thead.push_str("<th class='mc-net-col'>Net Δ</th></tr>");
12997 let mut metrics_tbody = String::new();
12998 for row in &rows {
12999 metrics_tbody.push_str("<tr>");
13000 write!(metrics_tbody, "<td class='mc-met-label'>{}</td>", row.label).unwrap();
13001 for i in 0..n {
13002 write!(
13003 metrics_tbody,
13004 "<td class='mc-val-col'>{}</td>",
13005 fmt_comma(row.values[i])
13006 )
13007 .unwrap();
13008 if i < n - 1 {
13009 let d = row.seq_deltas[i];
13010 write!(
13011 metrics_tbody,
13012 "<td class='mc-delta-col {cls}'>{val}</td>",
13013 cls = multi_delta_class(d),
13014 val = multi_fmt_delta(d)
13015 )
13016 .unwrap();
13017 }
13018 }
13019 let nd = row.net_delta;
13020 write!(
13021 metrics_tbody,
13022 "<td class='mc-net-col {cls}'>{val}</td>",
13023 cls = multi_delta_class(nd),
13024 val = multi_fmt_delta(nd)
13025 )
13026 .unwrap();
13027 metrics_tbody.push_str("</tr>");
13028 }
13029 (metrics_thead, metrics_tbody)
13030}
13031
13032fn build_mc_points_json(multi: &MultiScanComparison, entries: &[RegistryEntry]) -> String {
13034 let mut parts: Vec<String> = Vec::with_capacity(multi.points.len());
13035 for (i, pt) in multi.points.iter().enumerate() {
13036 let commit = pt.git_commit.as_deref().unwrap_or("");
13037 let branch = pt.git_branch.as_deref().unwrap_or("");
13038 let tags = pt.git_tags.as_deref().unwrap_or("");
13039 let nearest = pt.git_nearest_tag.as_deref().unwrap_or("");
13040 let scanned_ms = pt.timestamp.timestamp_millis();
13041 let scanned = pt.timestamp.format("%Y-%m-%d %H:%M UTC").to_string();
13042 let entry = entries.get(i).filter(|e| e.run_id == pt.run_id);
13043 let commit_date = entry
13044 .and_then(|e| e.git_commit_date.as_deref())
13045 .and_then(fmt_git_date)
13046 .unwrap_or_default();
13047 let author = entry
13048 .and_then(|e| e.git_author.as_deref())
13049 .unwrap_or("")
13050 .to_string();
13051 let cov = pt
13052 .coverage_line_pct
13053 .map_or_else(|| "null".to_string(), |v| format!("{v:.1}"));
13054 parts.push(format!(
13055 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}}}"#,
13056 run_id = js_escape(&pt.run_id),
13057 commit = js_escape(commit),
13058 branch = js_escape(branch),
13059 tags = js_escape(tags),
13060 nearest = js_escape(nearest),
13061 commit_date = js_escape(&commit_date),
13062 author = js_escape(&author),
13063 scanned = js_escape(&scanned),
13064 code = pt.code_lines,
13065 comments = pt.comment_lines,
13066 blank = pt.blank_lines,
13067 files = pt.files_analyzed,
13068 tests = pt.test_count,
13069 ));
13070 }
13071 format!("[{}]", parts.join(","))
13072}
13073
13074fn build_mc_file_matrix_json(multi: &MultiScanComparison) -> String {
13076 let mut parts: Vec<String> = Vec::with_capacity(multi.file_matrix.len());
13077 for row in &multi.file_matrix {
13078 let lang = row.language.as_deref().unwrap_or("");
13079 let codes: Vec<String> = row
13080 .code_per_scan
13081 .iter()
13082 .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
13083 .collect();
13084 let deltas: Vec<String> = row
13085 .code_delta_per_scan
13086 .iter()
13087 .map(|v| v.map_or("null".to_string(), |x| x.to_string()))
13088 .collect();
13089 parts.push(format!(
13090 r#"{{"p":"{path}","l":"{lang}","s":"{status}","c":[{codes}],"d":[{deltas}],"t":{total}}}"#,
13091 path = row.relative_path.replace('\\', "/").replace('"', "\\\""),
13092 status = row.overall_status,
13093 codes = codes.join(","),
13094 deltas = deltas.join(","),
13095 total = row.total_code_delta,
13096 ));
13097 }
13098 format!("[{}]", parts.join(","))
13099}
13100
13101fn build_mc_file_col_headers(n: usize) -> String {
13103 use std::fmt::Write as _;
13104 let mut out = String::new();
13105 for i in 0..n {
13106 write!(out, "<th class='file-scan-col'>Scan {} Code</th>", i + 1).unwrap();
13107 if i < n - 1 {
13108 write!(
13109 out,
13110 "<th class='file-delta-col'>Δ→{}</th>",
13111 i + 2
13112 )
13113 .unwrap();
13114 }
13115 }
13116 out
13117}
13118
13119fn build_mc_scope_bar(
13121 has_submodule_data: bool,
13122 sub_names: &[String],
13123 runs_csv: &str,
13124 active_sub: Option<&str>,
13125 super_scope_active: bool,
13126) -> String {
13127 use std::fmt::Write as _;
13128 if !has_submodule_data {
13129 return String::new();
13130 }
13131 let base_url = format!("/multi-compare?runs={}", html_escape(runs_csv));
13132 let full_active = active_sub.is_none() && !super_scope_active;
13133 let mut bar = format!(
13134 r#"<div class="submod-scope-bar">
13135 <span class="submod-scope-label">
13136 <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>
13137 Scope:
13138 </span>
13139 <div class="submod-scope-divider"></div>
13140 <a class="submod-scope-btn{full_cls}" href="{base_url}" title="All files — super-repo and all submodules combined">Full scan</a>
13141 <a class="submod-scope-btn{super_cls}" href="{base_url}&scope=super" title="Only files not belonging to any submodule">Super-repo only</a>"#,
13142 full_cls = if full_active { " active" } else { "" },
13143 super_cls = if super_scope_active { " active" } else { "" },
13144 );
13145 for s in sub_names {
13146 let is_active = active_sub == Some(s.as_str());
13147 write!(
13148 bar,
13149 "\n <a class=\"submod-scope-btn{cls}\" href=\"{base_url}&sub={name_enc}\" title=\"Only files in submodule {name_esc}\">{name_esc}</a>",
13150 cls = if is_active { " active" } else { "" },
13151 name_enc = html_escape(s),
13152 name_esc = html_escape(s),
13153 )
13154 .unwrap();
13155 }
13156 bar.push_str("\n</div>");
13157 bar
13158}
13159
13160fn build_mc_scope_label(active_sub: Option<&str>, super_scope_active: bool) -> String {
13162 active_sub.map_or_else(
13163 || {
13164 if super_scope_active {
13165 "Super-repo only — ".to_string()
13166 } else {
13167 String::new()
13168 }
13169 },
13170 |s| format!("Submodule: {} — ", html_escape(s)),
13171 )
13172}
13173
13174#[allow(clippy::too_many_lines)]
13175#[allow(clippy::too_many_arguments)]
13176fn multi_compare_page(
13177 multi: &MultiScanComparison,
13178 project_label: &str,
13179 version: &str,
13180 csp_nonce: &str,
13181 has_submodule_data: bool,
13182 sub_names: &[String],
13183 runs_csv: &str,
13184 super_scope_active: bool,
13185 active_sub: Option<&str>,
13186 entries: &[RegistryEntry],
13187) -> String {
13188 let n = multi.points.len();
13189 let is_many = n > 4;
13190 let mc_strip_class = if is_many {
13191 "mc-strip mc-strip-grid"
13192 } else {
13193 "mc-strip"
13194 };
13195
13196 let scan_strip = build_mc_scan_strip(
13198 multi,
13199 entries,
13200 n,
13201 is_many,
13202 active_sub,
13203 super_scope_active,
13204 project_label,
13205 );
13206
13207 let (metrics_thead, metrics_tbody) = build_mc_metrics_table(multi, n);
13209
13210 let points_json = build_mc_points_json(multi, entries);
13212 let file_matrix_json = build_mc_file_matrix_json(multi);
13213
13214 let files_modified = multi
13216 .file_matrix
13217 .iter()
13218 .filter(|f| f.overall_status == "modified")
13219 .count();
13220 let files_added = multi
13221 .file_matrix
13222 .iter()
13223 .filter(|f| f.overall_status == "added")
13224 .count();
13225 let files_removed = multi
13226 .file_matrix
13227 .iter()
13228 .filter(|f| f.overall_status == "removed")
13229 .count();
13230 let files_unchanged = multi
13231 .file_matrix
13232 .iter()
13233 .filter(|f| f.overall_status == "unchanged")
13234 .count();
13235 let total_files = multi.file_matrix.len();
13236
13237 let file_col_headers = build_mc_file_col_headers(n);
13238 let nav_compare_active = "style=\"background:rgba(255,255,255,0.22);\"";
13239 let scope_bar_html = build_mc_scope_bar(
13240 has_submodule_data,
13241 sub_names,
13242 runs_csv,
13243 active_sub,
13244 super_scope_active,
13245 );
13246 let scope_label = build_mc_scope_label(active_sub, super_scope_active);
13247 let toast_assets = sloc_toast_assets(csp_nonce);
13248
13249 format!(
13250 r#"<!doctype html>
13251<html lang="en">
13252<head>
13253 <meta charset="utf-8">
13254 <meta name="viewport" content="width=device-width, initial-scale=1">
13255 <title>OxideSLOC | Multi-Scan Timeline — {project_label}</title>
13256 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
13257 <link rel="stylesheet" href="/static/app.css">
13258 <script src="/static/app.js"></script>
13259 <style nonce="{csp_nonce}">
13260 :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;}}
13261 *,*::before,*::after{{box-sizing:border-box;margin:0;padding:0;}}
13262 body{{background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,sans-serif;min-height:100vh;}}
13263 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;}}
13264 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
13265 .background-watermarks img{{position:absolute;opacity:0.15;filter:blur(0.3px);user-select:none;max-width:none;}}
13266 .code-particles{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
13267 .code-particle{{position:absolute;font-family:ui-monospace,monospace;font-size:13px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}}
13268 @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));}}}}
13269 .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);}}
13270 .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;}}
13271 @media(max-width:1920px){{.top-nav-inner{{max-width:1500px;}}.page{{max-width:1500px;}}}}
13272 @media(max-width:1400px){{.nav-right{{gap:6px;}}.nav-pill,.nav-dropdown-btn,.theme-toggle{{padding:0 10px;}}}}
13273 @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;}}}}
13274 .brand{{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}}
13275 .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));}}
13276 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
13277 .brand-title{{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}}
13278 .brand-subtitle{{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}}
13279 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}}
13280 .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;}}
13281 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
13282 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}}
13283 .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
13284 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
13285 .nav-dropdown{{position:relative;display:inline-flex;}}
13286 .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;}}
13287 .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
13288 .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;}}
13289 .nav-dropdown:hover .nav-dropdown-menu,.nav-dropdown:focus-within .nav-dropdown-menu{{opacity:1;visibility:visible;transition:opacity .13s,visibility 0s;}}
13290 .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);}}
13291 .nav-dropdown-menu a:last-child{{border-bottom:none;}}
13292 .nav-dropdown-menu a:hover{{background:rgba(255,255,255,0.14);color:#fff;}}
13293 .nav-dropdown-menu a svg{{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}}
13294 body:not(.dark-theme) .icon-sun{{display:none;}}
13295 body.dark-theme .icon-moon{{display:none;}}
13296 .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;}}
13297 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
13298 .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);}}
13299 .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;}}
13300 .settings-close:hover{{color:var(--text);background:var(--surface-2);}}
13301 .settings-close svg{{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}}
13302 .settings-modal-body{{padding:14px 16px 16px;}}
13303 .settings-modal-label{{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}}
13304 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
13305 .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;}}
13306 .scheme-swatch:hover{{border-color:var(--line-strong);transform:translateY(-1px);}}
13307 .scheme-swatch.active{{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}}
13308 .scheme-preview{{width:28px;height:28px;border-radius:7px;flex-shrink:0;}}
13309 .scheme-label{{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}}
13310 .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;}}
13311 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
13312 .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;}}
13313 .btn-back:hover{{background:var(--line);}}
13314 .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;}}
13315 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;}}
13316 .mc-desc{{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}}
13317 .mc-subtitle{{font-size:14px;color:var(--muted);margin:0 0 6px;}}
13318 .mc-strip{{display:flex;align-items:stretch;flex-wrap:wrap;gap:12px;overflow:visible;padding:8px 4px 6px;margin-bottom:20px;width:100%;}}
13319 .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;}}
13320 .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;}}
13321 .mc-hero-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:16px;flex-wrap:wrap;}}
13322 .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;}}
13323 .mc-card:hover{{box-shadow:0 10px 28px rgba(77,44,20,0.18);}}
13324 body.dark-theme .mc-card{{background:var(--surface-2);}}
13325 .mc-card-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:10px;}}
13326 .mc-card-num{{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);}}
13327 .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%;}}
13328 .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;}}
13329 .mc-card-commit:hover{{color:var(--oxide);}}
13330 .mc-card-rows{{display:flex;flex-direction:column;gap:6px;}}
13331 .mc-card-row{{display:flex;align-items:baseline;gap:8px;font-size:13px;}}
13332 .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;}}
13333 .mc-row-val{{color:var(--text);font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1;}}
13334 .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;}}
13335 .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;}}
13336 .mc-card-project-col{{display:flex;flex-direction:column;align-items:flex-end;gap:5px;max-width:72%;}}
13337 .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;}}
13338 .mc-scope-full{{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}}
13339 .mc-scope-sub{{background:rgba(111,155,255,0.10);border:1px solid rgba(111,155,255,0.28);color:var(--accent);}}
13340 .mc-scope-super{{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.28);color:var(--oxide);}}
13341 .mc-card-nearest-wrap{{position:relative;display:inline-flex;align-items:center;gap:4px;cursor:default;}}
13342 .mc-card-nearest{{font-size:10px;color:var(--muted-2);font-style:italic;}}
13343 .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);}}
13344 .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);}}
13345 .mc-card-nearest-wrap:hover .mc-card-nearest-tip{{display:block;}}
13346 .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;}}
13347 .cmp-author-handle{{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}}
13348 .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;}}
13349 .submod-scope-divider{{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}}
13350 .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;}}
13351 .submod-scope-label svg{{stroke:currentColor;fill:none;stroke-width:2;}}
13352 .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;}}
13353 .submod-scope-btn:hover{{background:var(--line);}}
13354 .submod-scope-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
13355 .mc-arrow{{font-size:22px;color:var(--muted);align-self:center;padding:0 4px;flex-shrink:0;}}
13356 .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;}}
13357 .panel-title{{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}}
13358 .metrics-table{{width:100%;border-collapse:collapse;font-size:13px;}}
13359 .metrics-table th,.metrics-table td{{padding:9px 12px;border-bottom:1px solid var(--line);text-align:right;}}
13360 .metrics-table th{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);background:var(--surface-2);}}
13361 .metrics-table td.mc-met-label,.metrics-table th.mc-met-label{{text-align:left;font-weight:700;color:var(--text);}}
13362 .metrics-table .mc-val-col{{font-weight:700;font-variant-numeric:tabular-nums;}}
13363 .metrics-table .mc-delta-col{{font-size:12px;font-weight:700;font-variant-numeric:tabular-nums;}}
13364 .metrics-table .mc-net-col{{font-weight:800;font-size:13px;font-variant-numeric:tabular-nums;background:rgba(111,155,255,0.06);}}
13365 .metrics-table .pos{{color:var(--pos);}}
13366 .metrics-table .neg{{color:var(--neg);}}
13367 .metrics-table .zero{{color:var(--muted);}}
13368 .metrics-table tr:hover td{{background:rgba(211,122,76,0.04);}}
13369 .chart-toolbar{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
13370 .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;}}
13371 .chart-metric-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
13372 .chart-metric-btn:hover:not(.active){{background:var(--line);}}
13373 .chart-wrap{{width:100%;overflow-x:auto;}}
13374 #mc-chart{{display:block;width:100%;}}
13375 h2,.mc-charts-h2{{font-size:14px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 14px;}}
13376 .export-group{{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:4px;}}
13377 .ic-grid{{display:grid;grid-template-columns:1fr 1fr;gap:18px;}}
13378 @media(max-width:800px){{.ic-grid{{grid-template-columns:1fr;}}}}
13379 .ic-card{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
13380 body.dark-theme .ic-card{{background:var(--surface);border-color:var(--line-strong);}}
13381 .ic-card-h2{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin:0;}}
13382 .ic-card-h2-row{{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:12px;flex-wrap:wrap;}}
13383 .ic-card-h2-row .ic-card-h2{{margin:0;}}
13384 .ic-chart-hdr{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
13385 .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;}}
13386 .ic-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
13387 .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;}}
13388 .ic-svg-modal-ov.open{{display:flex;}}
13389 .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);}}
13390 .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);}}
13391 .ic-svg-modal-title{{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}}
13392 .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;}}
13393 .ic-svg-modal-close:hover{{background:var(--line);}}
13394 .ic-leg{{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}}
13395 .ic-dot{{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}}
13396 .ic-cb{{cursor:pointer;transition:opacity .17s,filter .17s,transform .17s;transform-box:fill-box;transform-origin:center center;}}
13397 .ic-cb:hover{{filter:brightness(1.15) drop-shadow(0 2px 6px rgba(0,0,0,.18));transform:scale(1.05);}}
13398 .ic-leg-item{{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}}
13399 .ic-leg-item:hover{{background:rgba(211,122,76,0.08);}}
13400 #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;}}
13401 .filter-tabs-row{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;}}
13402 .delta-note{{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}}
13403 .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;}}
13404 .tab-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
13405 .tab-btn:hover:not(.active){{background:var(--line);}}
13406 .tab-btn.tab-modified{{background:#fff2d8;color:#926000;border-color:#e6c96c;}}
13407 .tab-btn.tab-modified.active{{background:#926000;border-color:#926000;color:#fff;}}
13408 .tab-btn.tab-added{{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}}
13409 .tab-btn.tab-added.active{{background:#1a8f47;border-color:#1a8f47;color:#fff;}}
13410 .tab-btn.tab-removed{{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}}
13411 .tab-btn.tab-removed.active{{background:#b33b3b;border-color:#b33b3b;color:#fff;}}
13412 body.dark-theme .tab-btn.tab-modified{{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}}
13413 body.dark-theme .tab-btn.tab-added{{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}}
13414 body.dark-theme .tab-btn.tab-removed{{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}}
13415 .table-wrap{{width:100%;overflow-x:auto;}}
13416 #file-table{{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}}
13417 #file-table th,#file-table td{{padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap;}}
13418 #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;}}
13419 #file-table th.left,#file-table td.left{{text-align:left;}}
13420 .file-scan-col,.file-delta-col,.file-net-col{{text-align:right;font-variant-numeric:tabular-nums;font-weight:600;}}
13421 .file-delta-col{{color:var(--muted);font-size:11px;}}
13422 .file-net-col{{font-weight:800;}}
13423 .pos{{color:var(--pos);}} .neg{{color:var(--neg);}} .zero{{color:var(--muted);}}
13424 #file-table th.sortable{{cursor:pointer;user-select:none;}} #file-table th.sortable:hover{{color:var(--oxide);}}
13425 #file-table .sort-icon{{margin-left:3px;font-size:9px;opacity:.4;vertical-align:middle;}}
13426 #file-table th.sort-asc .sort-icon,#file-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
13427 .status-badge{{padding:2px 7px;border-radius:4px;font-size:10px;font-weight:700;text-transform:uppercase;}}
13428 .status-badge.modified{{background:#fff2d8;color:#926000;}}
13429 .status-badge.added{{background:#e8f5ed;color:#1a8f47;}}
13430 .status-badge.removed{{background:#fdeaea;color:#b33b3b;}}
13431 .status-badge.unchanged{{background:var(--surface-2);color:var(--muted);}}
13432 body.dark-theme .status-badge.modified{{background:#3d2f0a;color:#f0c060;}}
13433 body.dark-theme .status-badge.added{{background:#163927;color:#8fe2a8;}}
13434 body.dark-theme .status-badge.removed{{background:#3d1c1c;color:#f5a3a3;}}
13435 tr.row-added td{{background:rgba(26,143,71,0.04);}}
13436 tr.row-removed td{{background:rgba(179,59,59,0.06);}}
13437 tr.row-modified td{{background:rgba(146,96,0,0.04);}}
13438 tr.row-unchanged td{{color:var(--muted);}}
13439 tr.row-unchanged .status-badge{{opacity:.65;}}
13440 .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;}}
13441 .absent{{color:var(--muted);font-style:italic;}}
13442 .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
13443 .pagination-info{{font-size:12px;color:var(--muted);}}
13444 .pagination-btns{{display:flex;gap:5px;}}
13445 .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;}}
13446 .pg-btn:hover:not(:disabled){{background:var(--line);}}
13447 .pg-btn.active{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
13448 .pg-btn:disabled{{opacity:.35;cursor:default;}}
13449 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;}}
13450 .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;}}
13451 .export-btn:hover{{background:var(--line);}}
13452 .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;}}
13453 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
13454 .site-footer a{{color:var(--muted);}}
13455 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;}}
13456 body.pdf-mode{{background:#fff!important;}}
13457 body.pdf-mode .page{{padding:4px 6px 4px!important;}}
13458 .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;}}
13459 .mc-modal-overlay.open{{opacity:1;pointer-events:auto;}}
13460 .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;}}
13461 .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;}}
13462 .mc-modal-title{{font-size:18px;font-weight:800;}}
13463 .mc-modal-sub{{font-size:12px;opacity:.72;margin-top:3px;word-break:break-all;}}
13464 .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;}}
13465 .mc-modal-close:hover{{background:rgba(255,255,255,0.32);}}
13466 .mc-modal-body{{padding:18px 22px;}}
13467 .mc-modal-sec{{margin-bottom:20px;}}
13468 .mc-modal-sec-title{{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:10px;}}
13469 .mc-modal-stats{{display:flex;flex-wrap:nowrap;gap:8px;margin-bottom:8px;}}
13470 .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;}}
13471 .mc-modal-stat:hover{{transform:translateY(-3px);box-shadow:0 8px 22px rgba(196,92,16,0.20);border-color:var(--oxide);}}
13472 .mc-modal-stat-val{{font-size:17px;font-weight:900;color:var(--oxide);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}
13473 .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;}}
13474 .mc-modal-row{{display:flex;gap:14px;font-size:14px;padding:9px 0;border-bottom:1px solid var(--line);align-items:baseline;}}
13475 .mc-modal-row:last-child{{border-bottom:none;}}
13476 .mc-modal-key{{color:var(--muted);font-weight:700;font-size:12px;text-transform:uppercase;letter-spacing:.04em;flex-shrink:0;min-width:160px;}}
13477 .mc-modal-val{{color:var(--text);font-size:14.5px;font-weight:600;word-break:break-all;}}
13478 .mc-modal-val a{{color:var(--oxide);text-decoration:none;font-weight:700;}}
13479 .mc-modal-val a:hover{{text-decoration:underline;}}
13480 body.dark-theme .mc-modal-stat{{background:rgba(255,255,255,0.07);}}
13481 body.dark-theme .mc-modal-stat:hover{{box-shadow:0 8px 22px rgba(0,0,0,0.40);}}
13482 .mc-modal-stat[data-tip]{{cursor:help;}}
13483 #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);}}
13484 .mc-card{{cursor:pointer;}}
13485 .mc-card:hover{{transform:translateY(-4px);box-shadow:0 10px 28px rgba(196,92,16,0.24);z-index:10;}}
13486 </style>
13487</head>
13488<body>
13489 {loading_overlay}
13490 <div class="background-watermarks" aria-hidden="true">
13491 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
13492 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
13493 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
13494 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
13495 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
13496 <img src="/images/logo/logo-text.png" alt=""><img src="/images/logo/logo-text.png" alt="">
13497 </div>
13498 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
13499 <div class="top-nav">
13500 <div class="top-nav-inner">
13501 <a class="brand" href="/">
13502 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
13503 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Multi-Scan Timeline</div></div>
13504 </a>
13505 <div class="nav-right">
13506 <a class="nav-pill" href="/">Home</a>
13507 <div class="nav-dropdown">
13508 <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>
13509 <div class="nav-dropdown-menu">
13510 <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>
13511 </div>
13512 </div>
13513 <a class="nav-pill" href="/compare-scans" {nav_compare_active}>Compare Scans</a>
13514 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
13515 <div class="nav-dropdown">
13516 <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>
13517 <div class="nav-dropdown-menu">
13518 <a href="/code-ownership"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>Code Ownership</a>
13519 <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>
13520 </div>
13521 </div>
13522 <div class="server-status-wrap" id="server-status-wrap">
13523 <div class="nav-pill server-online-pill" id="server-status-pill">
13524 <span class="status-dot" id="status-dot"></span>
13525 <span id="server-status-label">Server</span>
13526 <span class="sx-d60f2ef3" id="server-ping-ms" ></span>
13527 </div>
13528 <div class="server-status-tip">
13529 OxideSLOC is running — accessible on your network.
13530 <span class="sx-238af6bc" id="server-tip-ping" ></span>
13531 </div>
13532 </div>
13533 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
13534 <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>
13535 </button>
13536 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
13537 <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>
13538 <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>
13539 </button>
13540 </div>
13541 </div>
13542 </div>
13543
13544 <div class="page">
13545 <!-- Hero header -->
13546 <div class="mc-hero">
13547 <div class="mc-hero-header">
13548 <div>
13549 <div class="mc-title">Multi-Scan Timeline</div>
13550 <p class="mc-desc">Side-by-side metric comparison across multiple scans — code line progression, file changes, and language breakdown.</p>
13551 <div class="mc-subtitle">{scope_label}{n} scans · project: <strong>{project_label}</strong></div>
13552 </div>
13553 <div class="sx-9ca10e51" >
13554 <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>
13555 <div class="export-group" id="mc-top-export-group">
13556 <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>
13557 <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>
13558 </div>
13559 </div>
13560 </div>
13561 {scope_bar_html}
13562 <!-- Scan strip -->
13563 <div class="{mc_strip_class}">{scan_strip}</div>
13564 </div>
13565
13566 <!-- Summary metrics table -->
13567 <div class="panel">
13568 <div class="panel-title">Metric Progression</div>
13569 <div class="table-wrap">
13570 <table class="metrics-table">
13571 <thead>{metrics_thead}</thead>
13572 <tbody>{metrics_tbody}</tbody>
13573 </table>
13574 </div>
13575 </div>
13576
13577 <!-- Scan Charts -->
13578 <div class="panel" id="mc-charts-panel">
13579 <div class="panel-title sx-16dbf2a3" >Scan Delta Charts</div>
13580 <div class="ic-grid">
13581 <!-- Timeline line chart — spans full width -->
13582 <div class="ic-card sx-aeb7cdee" >
13583 <div class="ic-card-h2-row">
13584 <span class="ic-card-h2">Timeline</span>
13585 <div class="chart-toolbar sx-ab79ea2b" >
13586 <button class="chart-metric-btn active" data-metric="code">Code Lines</button>
13587 <button class="chart-metric-btn" data-metric="files">Files</button>
13588 <button class="chart-metric-btn" data-metric="comments">Comments</button>
13589 <button class="chart-metric-btn" data-metric="tests">Tests</button>
13590 <button class="chart-metric-btn" data-metric="cov">Coverage</button>
13591 </div>
13592 </div>
13593 <div class="chart-wrap"><svg id="mc-chart" height="280"></svg></div>
13594 </div>
13595 <!-- Code Metrics: Scan 1 vs Latest -->
13596 <div class="ic-card">
13597 <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>
13598 <div class="ic-leg"><span class="ic-leg-item" data-highlight="Code Lines"><span class="ic-dot sx-618fd811" ></span><span class="sx-d50d9131" >Code Lines</span></span><span class="ic-leg-item" data-highlight="Files"><span class="ic-dot sx-d94e9768" ></span><span class="sx-f6800712" >Files</span></span><span class="ic-leg-item" data-highlight="Comments"><span class="ic-dot sx-38f87134" ></span><span class="sx-c64494ae" >Comments</span></span><span class="sx-53b2a74a" >(faded = scan 1)</span></div>
13599 <div id="mc-ic-c1"></div>
13600 </div>
13601 <!-- Language Code Delta -->
13602 <div class="ic-card" id="mc-ic-lang-card">
13603 <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>
13604 <div class="sx-a89fa233" >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>
13605 <div id="mc-ic-c3"></div>
13606 </div>
13607 <!-- Delta by Metric -->
13608 <div class="ic-card">
13609 <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>
13610 <div id="mc-ic-c2"></div>
13611 </div>
13612 <!-- File Change Distribution -->
13613 <div class="ic-card">
13614 <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>
13615 <div id="mc-ic-c4"></div>
13616 </div>
13617 </div>
13618 </div>
13619
13620 <!-- File matrix table -->
13621 <div class="panel">
13622 <div class="panel-title">File Matrix <span class="sx-8bdabd9b" >{total_files} files</span></div>
13623 <div class="sx-a86a62cc" >
13624 <div class="filter-tabs-row sx-ce1e99f1" >
13625 <button class="tab-btn tab-all active" data-status="">All ({total_files})</button>
13626 <button class="tab-btn tab-modified" data-status="modified">Modified ({files_modified})</button>
13627 <button class="tab-btn tab-added" data-status="added">Added ({files_added})</button>
13628 <button class="tab-btn tab-removed" data-status="removed">Removed ({files_removed})</button>
13629 <button class="tab-btn tab-unchanged" data-status="unchanged">Unchanged ({files_unchanged})</button>
13630 </div>
13631 <div class="sx-9ca10e51" >
13632 <span class="delta-note">* Δ = delta (change from scan 1 → latest)</span>
13633 <div class="export-group">
13634 <button type="button" class="export-btn" id="mc-file-reset-btn">↻ Reset</button>
13635 <button type="button" class="export-btn" id="export-csv-btn">
13636 <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>
13637 CSV
13638 </button>
13639 <button type="button" class="export-btn" id="mc-file-xls-btn">
13640 <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>
13641 Excel
13642 </button>
13643 </div>
13644 </div>
13645 </div>
13646 <div class="table-wrap">
13647 <table id="file-table">
13648 <thead>
13649 <tr>
13650 <th class="left sortable" data-sort-col="p" data-sort-type="str">File <span class="sort-icon">↕</span></th>
13651 <th class="left sortable" data-sort-col="l" data-sort-type="str">Language <span class="sort-icon">↕</span></th>
13652 <th class="left sortable" data-sort-col="s" data-sort-type="str">Status <span class="sort-icon">↕</span></th>
13653 {file_col_headers}
13654 <th class="file-net-col sortable" data-sort-col="t" data-sort-type="num">Net Δ <span class="sort-icon">↕</span></th>
13655 </tr>
13656 </thead>
13657 <tbody id="file-tbody"></tbody>
13658 </table>
13659 </div>
13660 <div class="pagination">
13661 <span class="pagination-info" id="pg-info"></span>
13662 <div class="pagination-btns" id="pg-btns"></div>
13663 <div class="sx-047507c3" >
13664 <span class="sx-14f170bb" >Show</span>
13665 <select class="per-page" id="per-page-sel">
13666 <option value="25" selected>25 per page</option>
13667 <option value="50">50 per page</option>
13668 <option value="100">100 per page</option>
13669 </select>
13670 </div>
13671 </div>
13672 </div>
13673 </div>
13674
13675 <div id="mc-ic-tt"></div>
13676
13677 <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
13678 <div class="ic-svg-modal">
13679 <div class="ic-svg-modal-hdr">
13680 <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
13681 <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">× Close</button>
13682 </div>
13683 <div id="ic-svg-modal-body"></div>
13684 </div>
13685 </div>
13686
13687 <footer class="site-footer">
13688 oxide-sloc v{version} — local code metrics workbench ·
13689 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
13690 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
13691 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
13692 · <a href="/report-bug" rel="noopener">Report a Bug</a> · <a href="/api-docs" rel="noopener">REST API</a>
13693 </footer>
13694
13695 <script nonce="{csp_nonce}">
13696 (function(){{
13697 // ── Dark theme ───────────────────────────────────────────────────────────
13698 try{{if(localStorage.getItem('sloc-dark')==='1')document.body.classList.add('dark-theme');}}catch(e){{}}
13699 var renderInlineCharts=null;
13700 var tt=document.getElementById('theme-toggle');
13701 if(tt)tt.addEventListener('click',function(){{
13702 var on=document.body.classList.toggle('dark-theme');
13703 try{{localStorage.setItem('sloc-dark',on?'1':'0');}}catch(e){{}}
13704 renderChart(activeMetric);
13705 if(renderInlineCharts)renderInlineCharts();
13706 }});
13707
13708 // ── Code particles ───────────────────────────────────────────────────────
13709 var container=document.getElementById('code-particles');
13710 if(container){{
13711 var snips = ['NUM sloc','fn analyze()','code_lines','0 mixed','blanks: NUM','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','NUM physical','files: NUM','NUM comments','cargo build','Ok(run)','Vec<GEN>','match lang','fn main()','.rs .go .py','sloc_core','render_html','NUM code','HashMap<GEN, GN2>','Option<GEN>','&str','author AUT','last_change','ratio 0.CPX','complexity CPX','tokens: NUM','?; // try','let owner','commits: NUM','#[tokio::test]','assert_eq!','PCT% cover','blame AUT','churn PCT%','HEAD~CPX','dedup PCT%','cyclomatic CPX','impl Iterator','.await','+NUM -NM2','ownership','Span<GEN>','pub struct','trait Lang','unsafe','mixed: NUM','anyhow::Result','fn detect()','PathBuf','+NUM loc','coverage PCT%','physical: NUM','ULOC NUM','dup PCT%','let mut run']; var __GT=['Author','String','Token','FileRecord','Lang','u32','usize','PathBuf','Commit','Line','Span','Metric','Record','u64','char','Utf8','Ratio','Blame']; var __AN=['nima','core','web','cli','git','lang','ci','bot','sloc'];
13712 for(var i=0;i<34;i++){{
13713 (function(idx){{
13714 var el=document.createElement('span');el.className='code-particle';
13715 el.textContent=snips[idx%snips.length].split('NUM').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('NM2').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('GEN').join(__GT[Math.floor(Math.random()*__GT.length)]).split('GN2').join(__GT[Math.floor(Math.random()*__GT.length)]).split('AUT').join(__AN[Math.floor(Math.random()*__AN.length)]).split('CPX').join(''+Math.floor(Math.random()*40+1)).split('PCT').join(''+Math.floor(Math.random()*44+1));
13716 el.style.left=(Math.random()*94+2).toFixed(1)+'%';
13717 el.style.top=(Math.random()*88+6).toFixed(1)+'%';
13718 el.style.setProperty('--rot',(Math.random()*26-13).toFixed(1)+'deg');
13719 el.style.setProperty('--op',(Math.random() * 0.096 + 0.06).toFixed(3));
13720 el.style.animationDuration=(Math.random()*10+9).toFixed(1)+'s';
13721 el.style.animationDelay='-'+(Math.random()*18).toFixed(1)+'s';
13722 container.appendChild(el);
13723 }})(i);
13724 }}
13725 }}
13726
13727 // ── Watermarks ───────────────────────────────────────────────────────────
13728 var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
13729 if(wms.length){{
13730 var placed=[];
13731 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;}}
13732 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];}}
13733 var half=Math.floor(wms.length/2);
13734 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;}});
13735 }}
13736
13737 // ── Settings / colour scheme modal ───────────────────────────────────────
13738 (function(){{
13739 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'}}];
13740 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);}});}}
13741 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a)ap(sv);else ap(S[0]);}}catch(e){{ap(S[0]);}}
13742 function init(){{
13743 var btn=document.getElementById('settings-btn');if(!btn)return;
13744 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
13745 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 class="sx-dec3b281" ><div class="settings-modal-label sx-c500155b" >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>';
13746 document.body.appendChild(m);
13747 var g=document.getElementById('scheme-grid');
13748 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);}});
13749 var cl=document.getElementById('settings-close-btn');
13750 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');}});
13751 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
13752 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
13753 }}
13754 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
13755 }})();
13756
13757 // ── Timezone support for scan timestamps ─────────────────────────────────
13758 (function(){{
13759 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);}};
13760 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'';}}}};
13761 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);}});}};
13762 var storedTz;try{{storedTz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{storedTz='America/Los_Angeles';}}
13763 window.applyTz(storedTz);
13764 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);}});}}}}
13765 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',wireTzSelect);else setTimeout(wireTzSelect,50);
13766 }})();
13767
13768 // ── Data ────────────────────────────────────────────────────────────────
13769 var POINTS={points_json};
13770 var FILES={file_matrix_json};
13771 var N={n};
13772
13773 // ── fmt helper ───────────────────────────────────────────────────────────
13774 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();}}
13775 function fmtFull(n){{return Number(n).toLocaleString();}}
13776 function fmtDelta(n){{return n>0?'+'+fmtFull(n):fmtFull(n);}}
13777
13778 // ── Export filename: <project>_<n_scans>_<first_scan_short_commit> ──
13779 function mcExportProj(){{return ('{project_label}'.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,''))||'project';}}
13780 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));}}
13781 function mcExportBase(){{var first=POINTS.length?mcShortRef(POINTS[0],0):'scan1';return mcExportProj()+'_'+POINTS.length+'_'+first;}}
13782 function mcExportName(ext){{return mcExportBase()+'.'+ext;}}
13783
13784 // ── Timeline chart ───────────────────────────────────────────────────────
13785 var activeMetric='code';
13786 var metricKey={{code:'code',files:'files',comments:'comments',tests:'tests',cov:'cov'}};
13787 var metricLabel={{code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'}};
13788
13789 function renderChart(metric){{
13790 var svg=document.getElementById('mc-chart');if(!svg)return;
13791 var W=svg.getBoundingClientRect().width||800,H=280;
13792 svg.setAttribute('height',H);
13793 var pad={{l:62,r:20,t:32,b:72}};
13794 var dark=document.body.classList.contains('dark-theme');
13795 var pts=POINTS.map(function(p){{return p[metric]!=null?Number(p[metric]):null;}});
13796 var valid=pts.filter(function(v){{return v!=null;}});
13797 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;}}
13798 var minV=0,maxV=Math.max.apply(null,valid);
13799 if(maxV<=0){{maxV=1;}}else{{maxV=maxV*1.08;}}
13800 var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
13801 function xOf(i){{return pad.l+(N===1?plotW/2:i/(N-1)*plotW);}}
13802 function yOf(v){{return pad.t+plotH-(v-minV)/(maxV-minV)*plotH;}}
13803 var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
13804 var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
13805 var lineColor='#d37a4c';var dotColor='#d37a4c';var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
13806 var parts=[];
13807 parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
13808 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>');}}
13809 var areaD='M '+xOf(0)+' '+(pad.t+plotH);
13810 var lineD='';var firstPt=true;
13811 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);}}}}
13812 areaD+=' L '+xOf(N-1)+' '+(pad.t+plotH)+' Z';
13813 parts.push('<path d="'+areaD+'" fill="'+areaColor+'"/>');
13814 parts.push('<path d="'+lineD+'" fill="none" stroke="'+lineColor+'" stroke-width="2.2" stroke-linejoin="round"/>');
13815 for(var i=0;i<N;i++){{
13816 if(pts[i]==null)continue;
13817 var cx=xOf(i),cy=yOf(pts[i]);
13818 var p=POINTS[i];var lbl=(p.commit||'').substring(0,7)||(i+1)+'';
13819 var hasTag=p.tags&&p.tags.length>0;
13820 // Permanent Y-value label above the dot
13821 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>');
13822 parts.push('<circle class="sx-9463ff47" 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" data-run-id="'+p.run_id+'"/>');
13823 var xanchor=i===0?'start':i===N-1?'end':'middle';
13824 // X-axis label at 2× the original size (18 px)
13825 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>');
13826 }}
13827 parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escHtml(metricLabel[metric]||metric)+'</text>');
13828 svg.setAttribute('viewBox','0 0 '+W+' '+H);
13829 svg.innerHTML=parts.join('');
13830 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');}});
13831 // ── Interactive hover: vertical crosshair + tooltip ───────────────────
13832 svg.onmousemove=function(e){{
13833 var rect=svg.getBoundingClientRect();
13834 var scaleX=W/rect.width;
13835 var mouseX=(e.clientX-rect.left)*scaleX;
13836 var nearest=-1,minDist=Infinity;
13837 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;}}}}
13838 if(nearest<0)return;
13839 var nc=xOf(nearest),ny=yOf(pts[nearest]);
13840 var xhair=svg.querySelector('.mc-xhair');
13841 if(!xhair){{xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','mc-xhair');svg.appendChild(xhair);}}
13842 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"/>';
13843 var tt=document.getElementById('mc-ic-tt');if(!tt)return;
13844 var pp=POINTS[nearest];var clbl=(pp.commit||'').substring(0,7)||(nearest+1)+'';
13845 tt.innerHTML='<strong>Scan '+(nearest+1)+'</strong> <span class="sx-0819fd61" >'+escHtml(clbl)+'</span><br>'+escHtml(metricLabel[metric]||metric)+': <strong>'+fmtFull(pts[nearest])+'</strong>';
13846 var bx=rect.left+(nc/W*rect.width)+18;
13847 if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
13848 tt.style.left=bx+'px';tt.style.top=(e.clientY-38)+'px';tt.style.display='block';
13849 }};
13850 svg.onmouseleave=function(){{
13851 var xhair=svg.querySelector('.mc-xhair');if(xhair)xhair.innerHTML='';
13852 var tt=document.getElementById('mc-ic-tt');if(tt)tt.style.display='none';
13853 }};
13854 }}
13855
13856 function escHtml(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
13857
13858 document.querySelectorAll('.chart-metric-btn').forEach(function(btn){{
13859 btn.addEventListener('click',function(){{
13860 activeMetric=this.dataset.metric;
13861 document.querySelectorAll('.chart-metric-btn').forEach(function(b){{b.classList.remove('active');}});
13862 this.classList.add('active');
13863 renderChart(activeMetric);
13864 }});
13865 }});
13866 if(typeof ResizeObserver!=='undefined'){{
13867 new ResizeObserver(function(){{renderChart(activeMetric);}}).observe(document.getElementById('mc-chart'));
13868 }}
13869 renderChart(activeMetric);
13870
13871 // ── File matrix table ────────────────────────────────────────────────────
13872 var activeStatus='';
13873 var currentPage=1;
13874 var perPage=25;
13875 var mcSortCol=null,mcSortAsc=true;
13876
13877 function getFiltered(){{
13878 var data=!activeStatus?FILES:FILES.filter(function(f){{return f.s===activeStatus;}});
13879 if(!mcSortCol)return data;
13880 var asc=mcSortAsc;
13881 return data.slice().sort(function(a,b){{
13882 var va,vb;
13883 if(mcSortCol==='p'){{va=a.p||'';vb=b.p||'';}}
13884 else if(mcSortCol==='l'){{va=a.l||'';vb=b.l||'';}}
13885 else if(mcSortCol==='s'){{va=a.s||'';vb=b.s||'';}}
13886 else if(mcSortCol==='t'){{va=a.t||0;vb=b.t||0;return asc?va-vb:vb-va;}}
13887 else{{return 0;}}
13888 if(asc)return va<vb?-1:va>vb?1:0;
13889 return va<vb?1:va>vb?-1:0;
13890 }});
13891 }}
13892
13893 function renderFilePage(){{
13894 var filtered=getFiltered();
13895 var total=filtered.length;
13896 var totalPages=Math.max(1,Math.ceil(total/perPage));
13897 if(currentPage>totalPages)currentPage=totalPages;
13898 var start=(currentPage-1)*perPage,end=Math.min(start+perPage,total);
13899 var tbody=document.getElementById('file-tbody');if(!tbody)return;
13900 var rows=[];
13901 for(var i=start;i<end;i++){{
13902 var f=filtered[i];
13903 var cells='<td class="left"><span class="file-path" title="'+escHtml(f.p)+'">'+escHtml(f.p)+'</span></td>';
13904 cells+='<td class="left">'+(f.l?escHtml(f.l):'<span class="absent">\u2014</span>')+'</td>';
13905 cells+='<td class="left"><span class="status-badge '+f.s+'">'+f.s+'</span></td>';
13906 for(var j=0;j<N;j++){{
13907 var cv=f.c[j];
13908 cells+='<td class="file-scan-col">'+(cv!=null?fmtFull(cv):'<span class="absent">\u2014</span>')+'</td>';
13909 if(j<N-1){{
13910 var dv=f.d[j+1];
13911 cells+='<td class="file-delta-col '+(dv!=null?dv>0?'pos':dv<0?'neg':'zero':'absent-delta')+'">'+
13912 (dv!=null?fmtDelta(dv):'<span class="absent">\u2014</span>')+'</td>';
13913 }}
13914 }}
13915 var tc=f.t;
13916 cells+='<td class="file-net-col '+(tc>0?'pos':tc<0?'neg':'zero')+'">'+fmtDelta(tc)+'</td>';
13917 rows.push('<tr class="row-'+f.s+'">'+cells+'</tr>');
13918 }}
13919 tbody.innerHTML=rows.join('');
13920
13921 var info=document.getElementById('pg-info');
13922 if(info)info.textContent='Showing '+(total?start+1:0)+'\u2013'+end+' of '+total+' files';
13923 renderPgBtns(totalPages);
13924 }}
13925
13926 function renderPgBtns(totalPages){{
13927 var wrap=document.getElementById('pg-btns');if(!wrap)return;
13928 var btns=[];
13929 function mkBtn(label,page,active,disabled){{
13930 var cls='pg-btn'+(active?' active':'')+(disabled?' disabled':'');
13931 return '<button class="'+cls+'" data-pg="'+page+'" '+(disabled?'disabled':'')+'>'+label+'</button>';
13932 }}
13933 btns.push(mkBtn('‹',currentPage-1,false,currentPage<=1));
13934 var s=Math.max(1,currentPage-2),e=Math.min(totalPages,currentPage+2);
13935 if(s>1)btns.push(mkBtn('1',1,false,false));
13936 if(s>2)btns.push('<span class="pg-btn sx-d4e8f245" >…</span>');
13937 for(var p=s;p<=e;p++)btns.push(mkBtn(p,p,p===currentPage,false));
13938 if(e<totalPages-1)btns.push('<span class="pg-btn sx-d4e8f245" >…</span>');
13939 if(e<totalPages)btns.push(mkBtn(totalPages,totalPages,false,false));
13940 btns.push(mkBtn('›',currentPage+1,false,currentPage>=totalPages));
13941 wrap.innerHTML=btns.join('');
13942 wrap.querySelectorAll('.pg-btn[data-pg]').forEach(function(b){{
13943 b.addEventListener('click',function(){{
13944 var pg=parseInt(this.dataset.pg,10);
13945 if(pg>=1&&pg<=totalPages){{currentPage=pg;renderFilePage();}}
13946 }});
13947 }});
13948 }}
13949
13950 // Tab filter
13951 document.querySelectorAll('.tab-btn').forEach(function(btn){{
13952 btn.addEventListener('click',function(){{
13953 activeStatus=this.dataset.status||'';
13954 currentPage=1;
13955 document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
13956 this.classList.add('active');
13957 renderFilePage();
13958 }});
13959 }});
13960
13961 // Per-page selector
13962 var ppSel=document.getElementById('per-page-sel');
13963 if(ppSel)ppSel.addEventListener('change',function(){{perPage=parseInt(this.value,10)||25;currentPage=1;renderFilePage();}});
13964
13965 // ── Column header sort ───────────────────────────────────────────────────
13966 Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(th){{
13967 th.addEventListener('click',function(){{
13968 var col=th.dataset.sortCol;
13969 if(mcSortCol===col){{mcSortAsc=!mcSortAsc;}}else{{mcSortCol=col;mcSortAsc=true;}}
13970 Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
13971 var si=t.querySelector('.sort-icon');if(si)si.innerHTML='↕';t.classList.remove('sort-asc','sort-desc');
13972 }});
13973 th.classList.add(mcSortAsc?'sort-asc':'sort-desc');
13974 var si=th.querySelector('.sort-icon');if(si)si.innerHTML=mcSortAsc?'↑':'↓';
13975 currentPage=1;renderFilePage();
13976 }});
13977 }});
13978
13979 // Reset button also clears sort
13980 var mcResetBtn=document.getElementById('mc-file-reset-btn');
13981 if(mcResetBtn)mcResetBtn.addEventListener('click',function(){{
13982 mcSortCol=null;mcSortAsc=true;
13983 Array.prototype.slice.call(document.querySelectorAll('#file-table th.sortable')).forEach(function(t){{
13984 var si=t.querySelector('.sort-icon');if(si)si.innerHTML='↕';t.classList.remove('sort-asc','sort-desc');
13985 }});
13986 activeStatus='';currentPage=1;
13987 document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
13988 var allBtn=document.querySelector('.tab-btn');if(allBtn)allBtn.classList.add('active');
13989 renderFilePage();
13990 }});
13991
13992 renderFilePage();
13993
13994 // ── CSV export ───────────────────────────────────────────────────────────
13995 var exportBtn=document.getElementById('export-csv-btn');
13996 if(exportBtn)exportBtn.addEventListener('click',function(){{
13997 var header=['File','Language','Status'];
13998 for(var i=0;i<N;i++){{header.push('Scan '+(i+1)+' Code');if(i<N-1)header.push('Delta->'+(i+2));}}
13999 header.push('Net Delta');
14000 var rows=[header.map(function(h){{return '"'+h.replace(/"/g,'""')+'"';}}).join(',')];
14001 var filtered=getFiltered();
14002 filtered.forEach(function(f){{
14003 var cols=['"'+f.p.replace(/"/g,'""')+'"','"'+(f.l||'')+'"','"'+f.s+'"'];
14004 for(var j=0;j<N;j++){{
14005 cols.push(f.c[j]!=null?f.c[j]:'');
14006 if(j<N-1)cols.push(f.d[j+1]!=null?f.d[j+1]:'');
14007 }}
14008 cols.push(f.t);
14009 rows.push(cols.join(','));
14010 }});
14011 var blob=new Blob([rows.join('\r\n')],{{type:'text/csv'}});
14012 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
14013 a.download=mcExportName('csv');a.click();
14014 }});
14015
14016 // ── File matrix extra export buttons ─────────────────────────────────────
14017 (function(){{
14018 var resetBtn=document.getElementById('mc-file-reset-btn');
14019 if(resetBtn)resetBtn.addEventListener('click',function(){{
14020 activeStatus='';currentPage=1;
14021 document.querySelectorAll('.tab-btn').forEach(function(b){{b.classList.remove('active');}});
14022 var allBtn=document.querySelector('.tab-btn.tab-all');if(allBtn)allBtn.classList.add('active');
14023 renderFilePage();
14024 }});
14025
14026 // \u2500\u2500 File Matrix Excel export \u2014 Summary + File Delta tabs (matches Scan Delta) \u2500\u2500
14027 function mcSignDelta(v){{if(v==null||v==='')return'';var n=+v;return n>0?'+'+n:String(n);}}
14028 function mcMakeXlsx(fname){{
14029 var filtered=getFiltered();
14030 var enc=new TextEncoder();
14031 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;}}
14032 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;}}
14033 function u2(n){{return[n&0xFF,(n>>8)&0xFF];}}
14034 function u4(n){{return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}}
14035 var ss=[],si={{}};
14036 function S(v){{v=String(v==null?'':v);if(!(v in si)){{si[v]=ss.length;ss.push(v);}}return si[v];}}
14037 function xe(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
14038 function WS(){{
14039 var R=0,buf=[];
14040 function cl(c){{return String.fromCharCode(65+c);}}
14041 function sc(c,v,st){{return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'><v>'+S(v)+'</v></c>';}}
14042 function nc(c,v,st){{return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+(st?' s="'+st+'"':'')+'><v>'+(+v)+'</v></c>';}}
14043 function row(cells){{if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}}
14044 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>';}}
14045 return{{sc:sc,nc:nc,row:row,xml:xml}};
14046 }}
14047 function dstyle(v){{var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}}
14048 var proj=mcExportProj();
14049 // \u2500\u2500 Summary sheet \u2500\u2500
14050 var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
14051 r1(s1(0,'OxideSLOC \u2014 Multi-Scan Timeline Report',1));
14052 r1(s1(0,proj,2));
14053 var firstTs=POINTS.length?(POINTS[0].scanned||''):'',lastTs=POINTS.length?(POINTS[POINTS.length-1].scanned||''):'';
14054 r1(s1(0,firstTs+' \u2192 '+lastTs+' ('+N+' scans)',2));
14055 r1('');
14056 r1(s1(0,'SCAN SUMMARY',8));
14057 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));
14058 POINTS.forEach(function(p,i){{
14059 var sha=(p.commit||'').replace(/[^A-Za-z0-9]/g,'').slice(0,7);
14060 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));
14061 }});
14062 r1('');
14063 if(POINTS.length>1){{
14064 var pf=POINTS[0],pl=POINTS[POINTS.length-1];
14065 r1(s1(0,'NET CHANGE (Scan 1 \u2192 Scan '+N+')',8));
14066 r1(s1(0,'Metric',3)+s1(1,'Scan 1',3)+s1(2,'Scan '+N,3)+s1(3,'Delta',3));
14067 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)));}};
14068 nr('Code Lines',pf.code,pl.code);
14069 nr('Comment Lines',pf.comments,pl.comments);
14070 nr('Files Analyzed',pf.files,pl.files);
14071 nr('Tests',pf.tests,pl.tests);
14072 r1('');
14073 }}
14074 var cMod=0,cAdd=0,cRem=0,cUnch=0;
14075 FILES.forEach(function(f){{var s=f.s;if(s==='modified')cMod++;else if(s==='added')cAdd++;else if(s==='removed')cRem++;else cUnch++;}});
14076 var totF=FILES.length||1;
14077 function pct(n){{return(n/totF*100).toFixed(1)+'%';}}
14078 r1(s1(0,'FILE CHANGES',8));
14079 r1(s1(0,'Category',3)+s1(1,'Count',3)+s1(2,'% of Total',3));
14080 r1(s1(0,'Modified')+n1(1,cMod,4)+s1(2,pct(cMod)));
14081 r1(s1(0,'Added')+n1(1,cAdd,4)+s1(2,pct(cAdd)));
14082 r1(s1(0,'Removed')+n1(1,cRem,4)+s1(2,pct(cRem)));
14083 r1(s1(0,'Unchanged')+n1(1,cUnch,4)+s1(2,pct(cUnch)));
14084 r1(s1(0,'Total')+n1(1,cMod+cAdd+cRem+cUnch,4)+s1(2,pct(cMod+cAdd+cRem+cUnch)));
14085 var lm={{}};
14086 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;}});
14087 var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}});
14088 if(langs.length){{
14089 r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
14090 r1(s1(0,'Language',3)+s1(1,'Files',3)+s1(2,'Net Code Delta',3));
14091 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)));}});
14092 }}
14093 var sh1=W1.xml('<col min="1" max="1" width="22" customWidth="1"/><col min="2" max="8" width="15" customWidth="1"/>');
14094 // \u2500\u2500 File Delta sheet \u2500\u2500
14095 var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
14096 var hcells=s2(0,'File',3)+s2(1,'Language',3)+s2(2,'Status',3),hc=3;
14097 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);}}
14098 hcells+=s2(hc,'Net Delta',3);
14099 r2(hcells);
14100 filtered.forEach(function(f){{
14101 var cells=s2(0,f.p)+s2(1,f.l||'')+s2(2,f.s||''),c=3;
14102 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));}}}}
14103 var tv=mcSignDelta(f.t);cells+=s2(c,tv,dstyle(tv));
14104 r2(cells);
14105 }});
14106 var ncols=3+N+(N-1)+1;
14107 var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="'+ncols+'" width="13" customWidth="1"/>');
14108 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>';
14109 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
14110 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>',
14111 '_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>',
14112 '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>',
14113 '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>',
14114 '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>',
14115 'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2}};
14116 var zparts=[],zcds=[],zoff=0,znf=0;
14117 ['[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){{
14118 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
14119 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]);
14120 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);
14121 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));
14122 var cde=new Uint8Array(cda.length+nb.length);cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);zcds.push(cde);
14123 zoff+=entry.length;znf++;
14124 }});
14125 var cdSz=zcds.reduce(function(s,b){{return s+b.length;}},0);
14126 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]);
14127 var totalLen=zoff+cdSz+eocd.length,out=new Uint8Array(totalLen),pos=0;
14128 zparts.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
14129 zcds.forEach(function(b){{out.set(b,pos);pos+=b.length;}});
14130 out.set(new Uint8Array(eocd),pos);
14131 var blob=new Blob([out],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}});
14132 var a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
14133 }}
14134
14135 var xlsBtn=document.getElementById('mc-file-xls-btn');
14136 if(xlsBtn)xlsBtn.addEventListener('click',function(){{mcMakeXlsx(mcExportName('xlsx'));}});
14137
14138 // File matrix HTML export — interactive: sort by column, filter by status
14139 function mcFileBuildHtml(){{
14140 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
14141 var hdrs=['File','Language','Status'];
14142 for(var _i=0;_i<N;_i++){{hdrs.push('Scan '+(_i+1)+' Code');if(_i<N-1)hdrs.push('\u0394\u2192'+(_i+2));}}
14143 hdrs.push('Net \u0394');
14144 var SI=2;
14145 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;}});
14146 var dJson=JSON.stringify(allRows),hJson=JSON.stringify(hdrs);
14147 var cnt={{all:allRows.length}};
14148 allRows.forEach(function(r){{var s=r[SI];cnt[s]=(cnt[s]||0)+1;}});
14149 var now=new Date().toISOString().replace('T',' ').slice(0,16)+' UTC';
14150 var css='body{{margin:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#f5f2ee;color:#111;}}'+
14151 '.hd{{background:#1a2035;color:#fff;padding:14px 20px;display:flex;justify-content:space-between;align-items:flex-start;}}'+
14152 '.brand{{font-size:13px;font-weight:800;color:#c45c10;letter-spacing:.06em;}}'+
14153 '.ttl{{font-size:18px;font-weight:700;margin:2px 0 3px;}}'+
14154 '.sub{{font-size:12px;color:#99aabb;}}'+
14155 '.pg-meta{{font-size:11px;color:#8899aa;text-align:right;line-height:1.8;}}'+
14156 '.wr{{padding:16px 20px;}}'+
14157 '.fbar{{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;}}'+
14158 '.fb{{padding:4px 12px;border-radius:20px;border:1px solid #ccc;background:#fff;font-size:12px;font-weight:600;cursor:pointer;transition:all .12s;}}'+
14159 '.fb.on{{background:#c45c10;color:#fff;border-color:#c45c10;}}'+
14160 '.ibar{{font-size:12px;color:#888;margin-bottom:8px;}}'+
14161 '.tw{{overflow-x:auto;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,.09);}}'+
14162 'table{{width:100%;border-collapse:collapse;background:#fff;font-size:12px;}}'+
14163 'thead tr{{background:#1a2035;}}'+
14164 '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;}}'+
14165 'th:hover{{background:#2a3050;}}'+
14166 'th span{{margin-left:4px;opacity:.55;font-size:10px;}}'+
14167 'td{{padding:5px 10px;border-bottom:1px solid #f0ece8;}}'+
14168 'tr:nth-child(even) td{{background:#faf7f4;}}'+
14169 'tr:hover td{{background:#f5f0ea;}}'+
14170 '.ap{{color:#2a6846;font-weight:700;}}.an{{color:#b23030;font-weight:700;}}'+
14171 '.ftr{{background:#1a2035;color:#7a8b9c;font-size:10px;padding:7px 20px;display:flex;justify-content:space-between;margin-top:16px;}}';
14172 var thH=hdrs.map(function(h,i){{return'<th data-ci="'+i+'">'+esc(h)+'<span>\u21c5</span></th>';}}).join('');
14173 var fH='<button class="fb on" data-f="">All ('+allRows.length+')</button>'+
14174 (cnt.modified?'<button class="fb" data-f="modified">Modified ('+cnt.modified+')</button>':'')+
14175 (cnt.added?'<button class="fb" data-f="added">Added ('+cnt.added+')</button>':'')+
14176 (cnt.removed?'<button class="fb" data-f="removed">Removed ('+cnt.removed+')</button>':'')+
14177 (cnt.unchanged?'<button class="fb" data-f="unchanged">Unchanged ('+cnt.unchanged+')</button>':'');
14178 var inlineJs='var ALL='+dJson+',HDRS='+hJson+',SI='+SI+',sc=-1,sd=1,sf="";'+
14179 'function fc(v,ci){{if(v==null)return"—";var s=String(v);'+
14180 'if(ci===SI){{return s==="added"?"<span class=\\"ap\\">added<\\/span>":s==="removed"?"<span class=\\"an\\">removed<\\/span>":s||"—";}}'+
14181 '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>";}}'+
14182 'if(ci>=3&&typeof v==="number")return Number(v).toLocaleString();'+
14183 'return s.length>80?"<abbr title=\\""+s.replace(/"/g,""")+"\\" style=\\"cursor:help\\">"+s.slice(0,78)+"\u2026<\\/abbr>":esc(s);}}'+
14184 'function esc(s){{return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");}}'+
14185 'function render(){{var data=sf?ALL.filter(function(r){{return r[SI]===sf;}}):ALL.slice();'+
14186 'if(sc>=0)data.sort(function(a,b){{var av=a[sc],bv=b[sc];var an=Number(av),bn=Number(bv);'+
14187 'return(!isNaN(an)&&!isNaN(bn)?an-bn:String(av||"").localeCompare(String(bv||"")))*sd;}});'+
14188 '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("")'+
14189 '||"<tr><td colspan=\\""+HDRS.length+"\\" style=\\"text-align:center;color:#aaa;padding:14px\\">No files match.<\\/td><\\/tr>";'+
14190 'document.getElementById("ic").textContent=data.length+" of "+ALL.length+" files";}}'+
14191 'document.querySelectorAll(".fb").forEach(function(b){{b.onclick=function(){{sf=this.dataset.f||"";'+
14192 'document.querySelectorAll(".fb").forEach(function(x){{x.classList.remove("on");}});this.classList.add("on");render();}};}} );'+
14193 'document.querySelectorAll("th[data-ci]").forEach(function(th){{th.onclick=function(){{var ci=+this.dataset.ci;'+
14194 'sd=(sc===ci)?-sd:1;sc=ci;'+
14195 'document.querySelectorAll("th[data-ci]").forEach(function(t){{t.querySelector("span").textContent="\u21c5";}});'+
14196 'this.querySelector("span").textContent=sd>0?"\u25b2":"\u25bc";render();}};}} );'+
14197 'render();';
14198 return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>Multi-Scan File Matrix<\/title><style>'+css+'<\/style><\/head><body>'+
14199 '<div class="hd"><div><div class="brand">oxide-sloc<\/div><div class="ttl">Multi-Scan File Matrix<\/div>'+
14200 '<div class="sub">{project_label} · {n} scans<\/div><\/div>'+
14201 '<div class="pg-meta">'+allRows.length+' files<br>Generated: '+now+'<\/div><\/div>'+
14202 '<div class="wr"><div class="fbar">'+fH+'<\/div><div class="ibar" id="ic"><\/div>'+
14203 '<div class="tw"><table><thead><tr>'+thH+'<\/tr><\/thead><tbody id="tb"><\/tbody><\/table><\/div><\/div>'+
14204 '<div class="ftr"><span>oxide-sloc v{version}<\/span><span>Multi-Scan File Matrix<\/span><span>{project_label}<\/span><\/div>'+
14205 '<script>'+inlineJs+'<\/script><\/body><\/html>';
14206 }}
14207
14208 var htmlBtn=document.getElementById('mc-file-html-btn');
14209 if(htmlBtn)htmlBtn.addEventListener('click',function(){{
14210 var h=mcFileBuildHtml();
14211 var blob=new Blob([h],{{type:'text/html;charset=utf-8;'}});
14212 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
14213 a.download=mcExportName('files.html');a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
14214 }});
14215
14216 var pdfBtn=document.getElementById('mc-file-pdf-btn');
14217 if(pdfBtn)pdfBtn.addEventListener('click',function(){{
14218 window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('files.pdf'),button:pdfBtn}});
14219 }});
14220 }})();
14221
14222 // ── Inline scan charts (matching Scan Delta layout) ──────────────────────
14223 (function(){{
14224 var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
14225 // Deeper shade of each metric hue for "before"/Scan-1 bars — bold, not washed.
14226 var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
14227 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
14228 function fmt2(n){{return Number(n).toLocaleString();}}
14229 function px(n){{return Math.round(n);}}
14230 var _tt=document.getElementById('mc-ic-tt');
14231 function btt(l,v){{return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}}
14232 function addTT(el){{
14233 if(!el)return;
14234 el.addEventListener('mouseover',function(e){{
14235 var t=e.target.closest('[data-ttl]');
14236 if(t&&_tt){{
14237 var ttl=t.getAttribute('data-ttl');
14238 _tt.innerHTML='<strong>'+ttl+'</strong><br>'+t.getAttribute('data-ttv');
14239 _tt.style.display='block';mvTT(e);
14240 el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
14241 el.querySelectorAll('[data-ttl]').forEach(function(x){{if(x.getAttribute('data-ttl')===ttl)x.style.filter='brightness(1.2)';}});
14242 }} else {{
14243 if(_tt)_tt.style.display='none';
14244 el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
14245 }}
14246 }});
14247 el.addEventListener('mouseleave',function(){{
14248 if(_tt)_tt.style.display='none';
14249 el.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
14250 }});
14251 el.addEventListener('mousemove',function(e){{mvTT(e);}});
14252 }}
14253 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';}}
14254 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
14255 function buildCharts(){{
14256 if(N<2)return;
14257 var cs=getComputedStyle(document.body);
14258 function cv(name,fb){{var v=cs.getPropertyValue(name);return(v&&v.trim())||fb;}}
14259 var textCol=cv('--text','#43342d');
14260 var mutedCol=cv('--muted','#7b675b');
14261 var gFill=cv('--muted-2','#a08777');
14262 var LGY=cv('--line','#e6d0bf');
14263 var axisCol=cv('--line-strong','#d8bfad');
14264 var surf2col=cv('--surface-2','#f4ede4');
14265 var surfCol=cv('--surface','#fff8f0');
14266 var p0=POINTS[0],pLast=POINTS[N-1];
14267 var dark=document.body.classList.contains('dark-theme');
14268 var FADE=dark?'#524238':'#e6d0bf';
14269 var barBorder=dark?'rgba(255,255,255,0.40)':'rgba(0,0,0,0.62)';
14270 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;}}
14271 var c1mets=[
14272 {{l:'Code Lines',b:Number(p0.code),c:Number(pLast.code),bc:OXD,cc:OX}},
14273 {{l:'Files',b:Number(p0.files),c:Number(pLast.files),bc:GND,cc:GN}},
14274 {{l:'Comments',b:Number(p0.comments),c:Number(pLast.comments),bc:GDD,cc:GD}}
14275 ];
14276 var maxV1=niceMax(Math.max.apply(null,c1mets.map(function(m){{return Math.max(m.b,m.c);}}))||1);
14277 // Code Metrics chart — grows to fill the height its grid row settled to (the
14278 // Language Code Delta sibling usually drives that), so it never sits short at
14279 // the top of an over-tall cell. C1W is fixed; C1H scales with the cell.
14280 function drawC1(){{
14281 var C1W=620,C1H=200;
14282 var c1host=document.getElementById('mc-ic-c1');
14283 var c1card=c1host?c1host.closest('.ic-card'):null;
14284 if(c1host&&c1card&&c1host.clientWidth>0){{
14285 var avW=c1host.clientWidth;
14286 var availPx=(c1card.getBoundingClientRect().bottom-16)-c1host.getBoundingClientRect().top;
14287 var wantH=availPx*C1W/avW;
14288 if(wantH>C1H)C1H=wantH;
14289 }}
14290 var c1mt=40,c1mb=34,c1ml=58,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=54,c1gap=10;
14291 var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
14292 for(var gi=1;gi<=4;gi++){{
14293 var gy=c1mt+c1ph*(1-gi/4),gv=maxV1*gi/4;
14294 c1+='<line x1="'+c1ml+'" y1="'+px(gy)+'" x2="'+(C1W-c1mr)+'" y2="'+px(gy)+'" stroke="'+LGY+'" stroke-width="0.5" stroke-dasharray="4,3"/>';
14295 c1+='<text x="'+(c1ml-6)+'" y="'+(px(gy)+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt(gv)+'</text>';
14296 }}
14297 c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
14298 c1+='<text x="'+(c1ml-6)+'" y="'+px(c1mt+c1ph+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">0</text>';
14299 c1mets.forEach(function(m,i){{
14300 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
14301 var bh0=Math.max(c1ph*m.b/maxV1,2),bh1=Math.max(c1ph*m.c/maxV1,2);
14302 c1+='<text x="'+cx+'" y="18" text-anchor="middle" font-family="'+FONT+'" font-size="13" font-weight="700" fill="'+textCol+'">'+esc(m.l)+'</text>';
14303 c1+='<rect class="sx-83ac1cee"'+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" />';
14304 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>';
14305 c1+='<rect class="sx-83ac1cee"'+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" />';
14306 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>';
14307 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>';
14308 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>';
14309 }});
14310 c1+='</svg>';
14311 return c1;
14312 }}
14313 // Chart 2: Delta by Metric (net delta first scan to last)
14314 var mets=[
14315 {{l:'Code Lines',v:Number(pLast.code)-Number(p0.code),mc:'#C45C10'}},
14316 {{l:'Files Analyzed',v:Number(pLast.files)-Number(p0.files),mc:'#2A6846'}},
14317 {{l:'Comment Lines',v:Number(pLast.comments)-Number(p0.comments),mc:GD}}
14318 ];
14319 var maxD=Math.max.apply(null,mets.map(function(m){{return Math.abs(m.v);}}));maxD=maxD||1;
14320 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;
14321 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
14322 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
14323 mets.forEach(function(m,i){{
14324 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);
14325 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>';
14326 c2+='<rect class="sx-83ac1cee"'+btt(m.l,'Net delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3" />';
14327 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>';}}
14328 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>';}}
14329 }});
14330 c2+='</svg>';
14331 // Chart 3: Language Code Delta (from FILES net total_code_delta per language)
14332 var lm={{}};
14333 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;}});
14334 var langs=Object.keys(lm).sort(function(a,b){{return Math.abs(lm[b].d)-Math.abs(lm[a].d);}}).slice(0,12);
14335 function drawC3(){{
14336 if(!langs.length)return'';
14337 var maxLD=Math.max.apply(null,langs.map(function(l){{return Math.abs(lm[l].d);}}));maxLD=maxLD||1;
14338 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;
14339 var c3host=document.getElementById('mc-ic-c3');
14340 var c3card=document.getElementById('mc-ic-lang-card');
14341 var C3H=langs.length*30+24;
14342 if(c3host&&c3card&&c3host.clientWidth>0){{
14343 var avW=c3host.clientWidth;
14344 var availPx=(c3card.getBoundingClientRect().bottom-16)-c3host.getBoundingClientRect().top;
14345 var wantH=availPx*C3W/avW;
14346 if(wantH>C3H)C3H=wantH;
14347 }}
14348 var topPad=12,botPad=12,band=(C3H-topPad-botPad)/langs.length,barH=Math.min(22,band*0.5);
14349 var c3='<svg viewBox="0 0 '+C3W+' '+px(C3H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
14350 c3+='<line x1="'+cx3+'" y1="'+topPad+'" x2="'+cx3+'" y2="'+px(C3H-botPad)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
14351 langs.forEach(function(l,i){{
14352 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);
14353 c3+='<text x="'+(c3LW-7)+'" y="'+px(yc+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="'+textCol+'">'+esc(l)+'</text>';
14354 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"/>';
14355 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>';}}
14356 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>';}}
14357 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>';
14358 }});
14359 c3+='</svg>';
14360 return c3;
14361 }}
14362 // Chart 4: File Change Distribution (donut left, legend right, % on slices)
14363 var fm=0,fa=0,fr=0,fu=0;
14364 FILES.forEach(function(f){{if(f.s==='modified')fm++;else if(f.s==='added')fa++;else if(f.s==='removed')fr++;else fu++;}});
14365 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;}});
14366 var tot4=segs.reduce(function(a,s){{return a+s.v;}},0)||1;
14367 var C4W=380,C4H=210,cx4=104,cy4=105,Ro=80,Ri=50;
14368 function pctFill(c){{return c===FADE?textCol:'#ffffff';}}
14369 var c4='<svg class="sx-b5aafb81" viewBox="0 0 '+C4W+' '+C4H+'" width="100%" xmlns="http://www.w3.org/2000/svg">',ang4=-Math.PI/2;
14370 if(segs.length===1){{
14371 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"/>';
14372 c4+='<circle cx="'+cx4+'" cy="'+cy4+'" r="'+Ri+'" fill="'+surfCol+'"/>';
14373 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>';
14374 }} else {{
14375 segs.forEach(function(s){{
14376 var sw=Math.min(s.v/tot4*2*Math.PI,2*Math.PI-0.001),a2=ang4+sw;
14377 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);
14378 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);
14379 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"/>';
14380 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>';}}
14381 ang4+=sw;
14382 }});
14383 }}
14384 c4+='<text x="'+cx4+'" y="'+(cy4-2)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="bold" fill="'+textCol+'">'+fmt2(tot4)+'</text>';
14385 c4+='<text x="'+cx4+'" y="'+(cy4+15)+'" text-anchor="middle" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">total files</text>';
14386 var legX=212,legRowH=26,legBlockH=segs.length*legRowH,legStartY=cy4-legBlockH/2+legRowH/2;
14387 segs.forEach(function(s,i){{
14388 var ly=legStartY+i*legRowH,pct=px(s.v/tot4*100);
14389 c4+='<rect class="sx-83ac1cee"'+btt(s.l,fmt2(s.v)+' files • '+pct+'%')+' x="'+legX+'" y="'+px(ly-10)+'" width="13" height="13" fill="'+s.c+'" rx="2" />';
14390 c4+='<text class="sx-83ac1cee"'+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+'" >'+esc(s.l)+'</text>';
14391 c4+='<text x="'+(legX+20)+'" y="'+px(ly+15)+'" font-family="'+FONT+'" font-size="10" fill="'+mutedCol+'">'+fmt2(s.v)+' files • '+pct+'%</text>';
14392 }});
14393 c4+='</svg>';
14394 // Inject the fixed-size siblings first, then size Code Metrics (c1) and
14395 // Language Code Delta (c3) to fill the shared grid-row height. c1 is drawn
14396 // once at natural height to seed the row, then both are filled to the row the
14397 // grid settled to, so neither sits short at the top of an over-tall cell.
14398 var lc=document.getElementById('mc-ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
14399 var e2=document.getElementById('mc-ic-c2');if(e2)e2.innerHTML=c2;
14400 var e4=document.getElementById('mc-ic-c4');if(e4)e4.innerHTML=c4;
14401 var e1=document.getElementById('mc-ic-c1');if(e1)e1.innerHTML=drawC1();
14402 var e3=document.getElementById('mc-ic-c3');if(e3)e3.innerHTML=langs.length?drawC3():'<p class="sx-90171b6d" >No language delta.</p>';
14403 if(e1)e1.innerHTML=drawC1();
14404 }}
14405 buildCharts();
14406 renderInlineCharts=buildCharts;
14407 ['mc-ic-c1','mc-ic-c2','mc-ic-c3','mc-ic-c4'].forEach(function(id){{var el=document.getElementById(id);if(el)addTT(el);}});
14408 (function(){{
14409 var ov=document.getElementById('ic-svg-modal-ov');
14410 var body=document.getElementById('ic-svg-modal-body');
14411 var ttl=document.getElementById('ic-svg-modal-title');
14412 var closeBtn=document.getElementById('ic-svg-modal-close');
14413 if(!ov||!body)return;
14414 function close(){{ov.classList.remove('open');body.innerHTML='';}}
14415 function open(srcId,title){{
14416 var src=document.getElementById(srcId);if(!src)return;
14417 ttl.textContent=title||'';
14418 var card=src.closest('.ic-card');
14419 var legHtml='';
14420 if(card){{var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg sx-16dbf2a3" >'+leg.innerHTML+'</div>';}}
14421 body.innerHTML=legHtml+src.innerHTML;
14422 var svg=body.querySelector('svg');
14423 if(svg){{svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}}
14424 addTT(body);
14425 ov.classList.add('open');
14426 }}
14427 document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){{
14428 btn.addEventListener('click',function(){{open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));}});
14429 }});
14430 if(closeBtn)closeBtn.addEventListener('click',close);
14431 ov.addEventListener('click',function(e){{if(e.target===ov)close();}});
14432 document.addEventListener('keydown',function(e){{if(e.key==='Escape'&&ov.classList.contains('open'))close();}});
14433 }})();
14434
14435 // HTML legend hover → highlight matching SVG bars within the SAME card only
14436 document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){{
14437 var metric=leg.getAttribute('data-highlight');
14438 var parentCard=leg.closest('.ic-card');
14439 var chartEl=parentCard?parentCard.querySelector('[id]'):null;
14440 if(!chartEl)return;
14441 leg.addEventListener('mouseenter',function(){{
14442 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{
14443 if(x.getAttribute('data-ttl').indexOf(metric)===0){{
14444 x.style.filter='brightness(1.35) drop-shadow(0 2px 8px rgba(0,0,0,0.28))';
14445 x.style.opacity='1';
14446 }} else {{
14447 x.style.opacity='0.28';
14448 }}
14449 }});
14450 }});
14451 leg.addEventListener('mouseleave',function(){{
14452 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){{x.style.filter='';x.style.opacity='';}});
14453 }});
14454 }});
14455 // Author handles
14456 document.querySelectorAll('.cmp-author-val').forEach(function(el){{var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');}});
14457
14458 // ── Export helpers ────────────────────────────────────────────────────────
14459 // Fetch one image from the server and return a data-URI Promise
14460 function mcFetchUri(path){{
14461 return fetch(path).then(function(r){{return r.blob();}}).then(function(b){{
14462 return new Promise(function(res){{
14463 var rd=new FileReader();rd.onload=function(){{res(rd.result);}};rd.onerror=function(){{res('');}};rd.readAsDataURL(b);
14464 }});
14465 }}).catch(function(){{return '';}});
14466 }}
14467 // Replace /images/… src attrs in html with base64 data-URIs (async, callback)
14468 function mcInlineImgs(html,cb){{
14469 var paths=[],seen={{}};
14470 html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){{if(!seen[p]){{seen[p]=1;paths.push(p);}}return _;}});
14471 if(!paths.length){{cb(html);return;}}
14472 Promise.all(paths.map(function(p){{return mcFetchUri(p).then(function(u){{return{{p:p,u:u}};}}); }}))
14473 .then(function(rs){{rs.forEach(function(r){{if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');}});cb(html);}})
14474 .catch(function(){{cb(html);}});
14475 }}
14476 // Capture full-page HTML with all table rows visible
14477 function mcRawHtml(pdfMode){{
14478 if(pdfMode)document.body.classList.add('pdf-mode');
14479 var s=perPage,p=currentPage;perPage=FILES.length||999999;currentPage=1;renderFilePage();
14480 var html=window.sxSelfContain(document.documentElement.outerHTML);
14481 perPage=s;currentPage=p;renderFilePage();
14482 if(pdfMode)document.body.classList.remove('pdf-mode');
14483 return html;
14484 }}
14485
14486 // HTML export (full page with inlined images)
14487 function mcDoHtml(btn,fname){{
14488 var orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
14489 mcInlineImgs(mcRawHtml(false),function(html){{
14490 var blob=new Blob([html],{{type:'text/html;charset=utf-8;'}});
14491 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
14492 a.download=fname;a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},200);
14493 btn.disabled=false;btn.innerHTML=orig;
14494 }});
14495 }}
14496 // PDF export — comprehensive document-style report: full numbers, all sections
14497 function mcBuildPdfHtml(){{
14498 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
14499 function full(n){{if(n==null||n===''||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
14500 function dStr(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
14501 function dHtml(v){{var s=dStr(v);return Number(v)>0?'<span class="sx-e46b3d3d" >'+s+'</span>':Number(v)<0?'<span class="sx-4e307fd5" >'+s+'</span>':'<span>'+s+'</span>';}}
14502 var tz;try{{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}}catch(e){{tz='America/Los_Angeles';}}
14503 var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
14504 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)));}}
14505 var commitsList=POINTS.map(function(pt,i){{return esc(ptRef(pt,i));}}).join(', ');
14506 var p0=N>0?POINTS[0]:null,pLast=N>0?POINTS[N-1]:null;
14507 var codeDelta=(p0&&pLast)?Number(pLast.code)-Number(p0.code):null;
14508 // Header/footer flow in document order (NOT position:fixed) — a fixed
14509 // header repeats every printed page in Chromium and overlaps the content
14510 // below it, swallowing the first rows of pages 2+ and clipping the cards
14511 // on page 1. The table <thead> repeats per page natively, so every row
14512 // stays visible.
14513 var css='body{{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}}'+
14514 '.pdf-header{{-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
14515 '.pdf-footer{{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}}'+
14516 '.page-hdr{{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}}'+
14517 '.ph-brand{{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}}'+
14518 '.ph-brand em{{color:#c45c10;font-style:normal;}}'+
14519 '.ph-title{{font-size:14px;font-weight:600;color:#555;}}'+
14520 '.ph-date{{font-size:11px;color:#888;text-align:right;white-space:nowrap;}}'+
14521 '.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;}}'+
14522 '.ib-name{{font-size:13px;font-weight:800;color:#fff;}}'+
14523 '.ib-right{{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}}'+
14524 '.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;}}'+
14525 '.body{{padding:12px 18px 0;}}'+
14526 '.sg{{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}}'+
14527 '.sc{{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}}'+
14528 '.sv{{font-size:18px;font-weight:900;color:#c45c10;}}'+
14529 '.sl{{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}}'+
14530 '.sec{{margin-bottom:10px;}}'+
14531 '.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;}}'+
14532 'table{{width:100%;border-collapse:collapse;font-size:11px;}}'+
14533 '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;}}'+
14534 'td{{border-bottom:1px solid #eee;padding:3px 7px;vertical-align:middle;}}'+
14535 'tr:nth-child(even) td{{background:#faf8f6;}}';
14536 // ── Metric Progression ────────────────────────────────────────────────
14537 var hasTests=POINTS.some(function(pt){{return pt.tests!=null&&Number(pt.tests)>0;}});
14538 var hasCov=POINTS.some(function(pt){{return pt.cov!=null;}});
14539 var progHdr='<th>#</th><th>Scan Ref</th><th class="sx-5f326564" >Code Lines</th><th class="sx-5f326564" >Comments</th><th class="sx-5f326564" >Blank Lines</th><th class="sx-5f326564" >Files</th>';
14540 if(hasTests)progHdr+='<th class="sx-5f326564" >Tests</th>';
14541 if(hasCov)progHdr+='<th class="sx-5f326564" >Coverage</th>';
14542 var progRows=POINTS.map(function(pt,i){{
14543 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)));
14544 var r='<tr><td class="sx-bee502d4" >'+(i+1)+'</td><td>'+esc(lbl)+'</td>'+
14545 '<td class="sx-5f326564" >'+full(pt.code)+'</td>'+
14546 '<td class="sx-5f326564" >'+full(pt.comments)+'</td>'+
14547 '<td class="sx-5f326564" >'+full(pt.blank)+'</td>'+
14548 '<td class="sx-5f326564" >'+full(pt.files)+'</td>';
14549 if(hasTests)r+='<td class="sx-5f326564" >'+(pt.tests!=null&&Number(pt.tests)>0?full(pt.tests):'—')+'</td>';
14550 if(hasCov)r+='<td class="sx-5f326564" >'+(pt.cov!=null?Number(pt.cov).toFixed(1)+'%':'—')+'</td>';
14551 return r+'</tr>';
14552 }}).join('');
14553 // ── Scan-to-scan changes ──────────────────────────────────────────────
14554 var deltaRows=N>1?POINTS.slice(1).map(function(pt,i){{
14555 var prev=POINTS[i];
14556 var cd=Number(pt.code)-Number(prev.code),cm=Number(pt.comments)-Number(prev.comments);
14557 var bl=Number(pt.blank)-Number(prev.blank),fd=Number(pt.files)-Number(prev.files);
14558 return '<tr><td class="sx-eb985bc3" >'+esc(ptRef(prev,i))+' \u2192 '+esc(ptRef(pt,i+1))+'</td>'+
14559 '<td class="sx-5f326564" >'+dHtml(cd)+'</td>'+
14560 '<td class="sx-5f326564" >'+dHtml(cm)+'</td>'+
14561 '<td class="sx-5f326564" >'+dHtml(bl)+'</td>'+
14562 '<td class="sx-5f326564" >'+dHtml(fd)+'</td></tr>';
14563 }}).join(''):'';
14564 // ── File matrix (top 50 by |total delta|) ────────────────────────────
14565 var fmSection='';
14566 if(FILES&&FILES.length){{
14567 // Hard cap on per-scan columns so the table never overflows the page width.
14568 var MAXC=6;var startIdx=N>MAXC?N-MAXC:0;
14569 var topFiles=FILES.slice().sort(function(a,b){{return Math.abs(Number(b.t))-Math.abs(Number(a.t));}});
14570 var fmHdr='<th>File</th><th>Language</th><th>Status</th>';
14571 for(var fi=startIdx;fi<N;fi++)fmHdr+='<th class="sx-5f326564" >Scan '+(fi+1)+'</th>';
14572 fmHdr+='<th class="sx-5f326564" >Total \u0394</th>';
14573 var fmRows=topFiles.map(function(f){{
14574 var ss=f.s==='added'?'data-sx-style="color:#2a6846;font-weight:700"':f.s==='removed'?'data-sx-style="color:#b23030;font-weight:700"':'';
14575 var cols='';for(var fi=startIdx;fi<N;fi++)cols+='<td class="sx-5f326564" >'+(f.c[fi]!=null?Number(f.c[fi]).toLocaleString():'—')+'</td>';
14576 cols+='<td class="sx-5f326564" >'+dHtml(Number(f.t))+'</td>';
14577 var sp=f.p.length>55?'\u2026'+f.p.slice(-53):f.p;
14578 return '<tr><td class="sx-e22f660d" >'+esc(sp)+'</td><td>'+esc(f.l||'')+'</td><td '+ss+'>'+esc(f.s||'')+'</td>'+cols+'</tr>';
14579 }}).join('');
14580 var colNote=N>MAXC?' (latest '+MAXC+' scans shown)':'';
14581 fmSection='<div class="sec"><p class="sh">File Matrix \u2014 All '+FILES.length+' Files'+colNote+'</p>'+
14582 '<table><thead><tr>'+fmHdr+'</tr></thead><tbody>'+fmRows+'</tbody></table></div>';
14583 }}
14584 return '<!DOCTYPE html><html><head><meta charset="utf-8">'+
14585 '<title>OxideSLOC \u2014 Multi-Scan Timeline</title><style>'+css+'</style></head><body>'+
14586 '<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>'+
14587
14588 '<div class="body">'+
14589 '<div class="sg">'+
14590 (pLast?'<div class="sc"><div class="sv">'+full(pLast.code)+'</div><div class="sl">Latest Code Lines</div></div>':
14591 '<div class="sc"><div class="sv">—</div><div class="sl">Latest Code Lines</div></div>')+
14592 (pLast?'<div class="sc"><div class="sv">'+full(pLast.files)+'</div><div class="sl">Latest Files</div></div>':
14593 '<div class="sc"><div class="sv">—</div><div class="sl">Latest Files</div></div>')+
14594 (codeDelta!==null?'<div class="sc"><div class="sv" data-sx-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>':
14595 '<div class="sc"><div class="sv">—</div><div class="sl">Net Code Change</div></div>')+
14596 '<div class="sc"><div class="sv sx-55aba240" >{n}</div><div class="sl">Scans Compared</div></div>'+
14597 '</div>'+
14598 '<div class="sec"><p class="sh">Metric Progression</p>'+
14599 '<table><thead><tr>'+progHdr+'</tr></thead><tbody>'+progRows+'</tbody></table></div>'+
14600 (N>1?'<div class="sec"><p class="sh">Scan-to-Scan Changes</p>'+
14601 '<table><thead><tr><th class="sx-90500853" >Scans</th>'+
14602 '<th class="sx-5f326564" >Code \u0394</th><th class="sx-5f326564" >Comments \u0394</th>'+
14603 '<th class="sx-5f326564" >Blank \u0394</th><th class="sx-5f326564" >Files \u0394</th>'+
14604 '</tr></thead><tbody>'+deltaRows+'</tbody></table></div>':'')+
14605 fmSection+
14606 '</div>'+
14607 '<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>'+
14608 '</body></html>';
14609 }}
14610 function mcDoPdf(btn){{
14611 window.slocExportPdf({{html:mcBuildPdfHtml(),filename:mcExportName('pdf'),button:btn}});
14612 }}
14613
14614 var mcHtmlBtn=document.getElementById('mc-export-html-btn');
14615 if(mcHtmlBtn)mcHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcHtmlBtn,mcExportName('html'));}});
14616 var mcTopHtmlBtn=document.getElementById('mc-top-export-html-btn');
14617 if(mcTopHtmlBtn)mcTopHtmlBtn.addEventListener('click',function(){{mcDoHtml(mcTopHtmlBtn,mcExportName('html'));}});
14618 var mcPdfBtn=document.getElementById('mc-export-pdf-btn');
14619 if(mcPdfBtn)mcPdfBtn.addEventListener('click',function(){{mcDoPdf(mcPdfBtn);}});
14620 var mcTopPdfBtn=document.getElementById('mc-top-export-pdf-btn');
14621 if(mcTopPdfBtn)mcTopPdfBtn.addEventListener('click',function(){{mcDoPdf(mcTopPdfBtn);}});
14622 if(location.protocol==='file:'){{
14623 [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';}}}} );
14624 [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';}}}} );
14625 }}
14626 }})();
14627 // ── Scan card modal — document-level click delegation (no timing/parse-order deps) ──
14628 (function(){{
14629 function $(id){{return document.getElementById(id);}}
14630 function esc(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
14631 function full(n){{if(n==null||isNaN(Number(n)))return'\u2014';return Number(n).toLocaleString();}}
14632 function dS(v){{return Number(v)>0?'+'+Number(v).toLocaleString():Number(v).toLocaleString();}}
14633 function dSt(v){{return Number(v)>0?'color:#2a6846;font-weight:700':Number(v)<0?'color:#b23030;font-weight:700':'';}}
14634 function openModal(idx){{
14635 var ov=$('mc-modal-overlay');if(!ov)return;
14636 var titleEl=$('mc-modal-title'),subEl=$('mc-modal-sub'),bodyEl=$('mc-modal-body');
14637 if(idx<0||idx>=N)return;
14638 var pt=POINTS[idx];
14639 titleEl.textContent='Scan '+(idx+1);
14640 var lbl=pt.tags||(pt.branch?(pt.commit?pt.branch+' @ '+pt.commit:pt.branch):(pt.commit||'\u2014'));
14641 subEl.textContent=lbl;
14642 var sHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Metrics</div><div class="mc-modal-stats">'+
14643 '<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>'+
14644 '<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>'+
14645 '<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>'+
14646 '<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>'+
14647 (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>':'')+
14648 (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>':'')+
14649 '</div></div>';
14650 var iHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Scan Info</div>'+
14651 (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>':'')+
14652 (pt.branch?'<div class="mc-modal-row"><span class="mc-modal-key">Branch</span><span class="mc-modal-val">'+esc(pt.branch)+'</span></div>':'')+
14653 (pt.tags?'<div class="mc-modal-row"><span class="mc-modal-key">Tags</span><span class="mc-modal-val">'+esc(pt.tags)+'</span></div>':'')+
14654 (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>':'')+
14655 (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>':'')+
14656 (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>':'')+
14657 (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>':'')+
14658 '<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>'+
14659 '</div>';
14660 var dHtml='';
14661 if(idx>0){{
14662 var prev=POINTS[idx-1];
14663 var cd=Number(pt.code)-Number(prev.code),fd=Number(pt.files)-Number(prev.files),cm=Number(pt.comments)-Number(prev.comments);
14664 dHtml='<div class="mc-modal-sec"><div class="mc-modal-sec-title">Change vs Scan '+idx+'</div><div class="mc-modal-stats">'+
14665 '<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" data-sx-style="'+dSt(cd)+'">'+dS(cd)+'</div><div class="mc-modal-stat-lbl">Code \u0394</div></div>'+
14666 '<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" data-sx-style="'+dSt(fd)+'">'+dS(fd)+'</div><div class="mc-modal-stat-lbl">Files \u0394</div></div>'+
14667 '<div class="mc-modal-stat" data-tip="Net change in comment lines compared with the previous scan."><div class="mc-modal-stat-val" data-sx-style="'+dSt(cm)+'">'+dS(cm)+'</div><div class="mc-modal-stat-lbl">Comments \u0394</div></div>'+
14668 '</div></div>';
14669 }}
14670 bodyEl.innerHTML=sHtml+iHtml+dHtml;
14671 ov.classList.add('open');document.body.style.overflow='hidden';
14672 }}
14673 function closeModal(){{var ov=$('mc-modal-overlay');if(ov)ov.classList.remove('open');document.body.style.overflow='';}}
14674 // Delegated click: robust to parse order, re-renders, and missing-at-attach elements.
14675 document.addEventListener('click',function(e){{
14676 if(!e.target||!e.target.closest)return;
14677 if(e.target.closest('#mc-modal-close')){{closeModal();return;}}
14678 if(e.target.id==='mc-modal-overlay'){{closeModal();return;}}
14679 var card=e.target.closest('.mc-card');
14680 if(!card)return;
14681 if(e.target.closest('a'))return;
14682 var cards=Array.prototype.slice.call(document.querySelectorAll('.mc-card'));
14683 var i=cards.indexOf(card);
14684 if(i>=0)openModal(i);
14685 }});
14686 document.addEventListener('keydown',function(e){{if(e.key==='Escape')closeModal();}});
14687 // Styled hover description for the metric boxes (fixed tooltip, never clipped by the modal scroll area).
14688 var statTip=null;
14689 document.addEventListener('mousemove',function(e){{
14690 var box=(e.target&&e.target.closest)?e.target.closest('.mc-modal-stat[data-tip]'):null;
14691 if(!box){{if(statTip)statTip.style.display='none';return;}}
14692 if(!statTip){{statTip=document.createElement('div');statTip.id='mc-stat-tt';document.body.appendChild(statTip);}}
14693 var tip=box.getAttribute('data-tip')||'';
14694 if(statTip.textContent!==tip)statTip.textContent=tip;
14695 statTip.style.display='block';
14696 var w=statTip.offsetWidth,h=statTip.offsetHeight,x=e.clientX+14,y=e.clientY+16;
14697 if(x+w>window.innerWidth-8)x=e.clientX-w-14;
14698 if(y+h>window.innerHeight-8)y=e.clientY-h-16;
14699 statTip.style.left=(x<8?8:x)+'px';statTip.style.top=(y<8?8:y)+'px';
14700 }});
14701 (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');}})();
14702 }})();
14703 }})();
14704 </script>
14705 <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]';
14706 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;}}
14707 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>
14708 <!-- Scan card detail modal -->
14709 <div class="mc-modal-overlay" id="mc-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="mc-modal-title">
14710 <div class="mc-modal" id="mc-modal">
14711 <div class="mc-modal-head">
14712 <div><div class="mc-modal-title" id="mc-modal-title">Scan</div><div class="mc-modal-sub" id="mc-modal-sub"></div></div>
14713 <button class="mc-modal-close" id="mc-modal-close" aria-label="Close">✕</button>
14714 </div>
14715 <div class="mc-modal-body" id="mc-modal-body"></div>
14716 </div>
14717 </div>
14718 {toast_assets}
14719</body>
14720</html>"#,
14721 project_label = html_escape(project_label),
14722 n = n,
14723 scan_strip = scan_strip,
14724 mc_strip_class = mc_strip_class,
14725 metrics_thead = metrics_thead,
14726 metrics_tbody = metrics_tbody,
14727 file_col_headers = file_col_headers,
14728 total_files = total_files,
14729 files_modified = files_modified,
14730 files_added = files_added,
14731 files_removed = files_removed,
14732 files_unchanged = files_unchanged,
14733 points_json = points_json,
14734 file_matrix_json = file_matrix_json,
14735 nav_compare_active = nav_compare_active,
14736 version = version,
14737 csp_nonce = csp_nonce,
14738 scope_bar_html = scope_bar_html,
14739 scope_label = scope_label,
14740 loading_overlay = loading_overlay_block(csp_nonce, "Loading comparison"),
14741 )
14742}
14743
14744#[allow(clippy::too_many_lines)] async fn trend_report_handler(
14752 State(state): State<AppState>,
14753 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
14754) -> Response {
14755 auto_scan_watched_dirs(&state).await;
14756
14757 let watched_dirs_list: Vec<String> = {
14758 let wd = state.watched_dirs.lock().await;
14759 wd.dirs.iter().map(|p| p.display().to_string()).collect()
14760 };
14761
14762 let roots: Vec<String> = {
14764 let reg = state.registry.lock().await;
14765 let mut seen = std::collections::BTreeSet::new();
14766 reg.entries
14767 .iter()
14768 .flat_map(|e| e.input_roots.iter().cloned())
14769 .filter(|r| seen.insert(r.clone()))
14770 .collect()
14771 };
14772
14773 let roots_json = serde_json::to_string(&roots).unwrap_or_else(|_| "[]".to_string());
14774 let nonce = &csp_nonce;
14775 let version = env!("CARGO_PKG_VERSION");
14776 let toast_assets = sloc_toast_assets(nonce);
14777
14778 let watched_dirs_html: String = if state.server_mode {
14782 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()
14783 } else {
14784 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
14785 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
14786 .to_string()
14787 } else {
14788 watched_dirs_list
14789 .iter()
14790 .fold(String::new(), |mut s, d| {
14791 use std::fmt::Write as _;
14792 let escaped =
14793 d.replace('&', "&").replace('"', """).replace('<', "<");
14794 write!(
14795 s,
14796 r#"<span class="watched-chip"><span class="watched-chip-path" title="{escaped}">{escaped}</span><form class="sx-043808a9" method="POST" action="/watched-dirs/remove" ><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>"#
14797 ).expect("write to String is infallible");
14798 s
14799 })
14800 };
14801 format!(
14802 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 class="sx-043808a9" method="POST" action="/watched-dirs/refresh" ><input type="hidden" name="redirect_to" value="/trend-reports"><button type="submit" class="btn">↻ Refresh</button></form></div></div>"#
14803 )
14804 };
14805
14806 let html = format!(
14807 r##"<!doctype html>
14808<html lang="en">
14809<head>
14810 <meta charset="utf-8" />
14811 <meta name="viewport" content="width=device-width, initial-scale=1" />
14812 <title>OxideSLOC | Trend Reports</title>
14813 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
14814 <link rel="stylesheet" href="/static/app.css">
14815 <script src="/static/app.js"></script>
14816 <style nonce="{nonce}">
14817 :root {{
14818 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
14819 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
14820 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
14821 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
14822 --info-bg:#eef3ff; --info-text:#4467d8;
14823 }}
14824 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
14825 *{{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;}}
14826 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
14827 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
14828 .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:13px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}}
14829 @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));}}}}
14830 .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);}}
14831 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
14832 .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));}}
14833 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
14834 .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;}}
14835 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
14836 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
14837 @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; }} }}
14838 .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;}}
14839 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
14840 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
14841 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
14842 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
14843 .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;}}
14844 .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;}}
14845 .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;}}
14846 .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;}}
14847 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
14848 .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);}}
14849 .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;}}
14850 .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;}}
14851 .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;}}
14852 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
14853 .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;}}
14854 .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);}}
14855 .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;}}
14856 .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;}}
14857 .tz-select:focus{{border-color:var(--oxide);}}
14858 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
14859 @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
14860 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
14861 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
14862 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
14863 .trend-header{{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px;}}
14864 .trend-title-block{{flex:1;min-width:0;}}
14865 .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;}}
14866 .controls-centered label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
14867 .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;}}
14868 .chart-select:focus{{border-color:var(--accent);}}
14869 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
14870 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
14871 .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);}}
14872 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
14873 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
14874 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
14875 .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);}}
14876 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
14877 .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
14878 .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;}}
14879 .stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}
14880 body.dark-theme .stat-delta-up{{color:#5aba8a;}}body.dark-theme .stat-delta-down{{color:#e07070;}}
14881 .chart-wrap{{width:100%;overflow-x:auto;}} .chart-wrap svg{{display:block;margin:0 auto;}}
14882 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
14883 .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;}}
14884 .tr-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
14885 .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;}}
14886 .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);}}
14887 .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;}}
14888 .chart-hint-inline svg{{width:12px;height:12px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}}
14889 .chart-hint-inline .dot{{display:inline-block;width:8px;height:8px;border-radius:50%;vertical-align:middle;margin:0 1px;}}
14890 .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);}}
14891 .data-table{{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}}
14892 .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;}}
14893 .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;}}
14894 .data-table tr:last-child td{{border-bottom:none;}}
14895 .data-table tbody tr:hover td{{background:var(--surface-2);cursor:pointer;}}
14896 .num{{text-align:right;font-variant-numeric:tabular-nums;}}
14897 .table-wrap{{width:100%;overflow-x:auto;}}
14898 .data-table th.sortable{{cursor:pointer;}} .data-table th.sortable:hover{{color:var(--oxide);}}
14899 .sort-icon{{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}}
14900 .data-table th.sort-asc .sort-icon,.data-table th.sort-desc .sort-icon{{opacity:1;color:var(--oxide);}}
14901 .col-resize-handle{{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}}
14902 .col-resize-handle:hover,.col-resize-handle.dragging{{background:rgba(211,122,76,0.3);}}
14903 .filter-row{{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}}
14904 .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;}}
14905 .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;}}
14906 .pagination{{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:14px;flex-wrap:wrap;}}
14907 .pagination-info{{font-size:13px;color:var(--muted);}}
14908 .pagination-btns{{display:flex;gap:6px;}}
14909 .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;}}
14910 .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;}}
14911 #scan-history-table col:nth-child(1){{width:155px;}}
14912 #scan-history-table col:nth-child(2){{width:240px;}}
14913 #scan-history-table col:nth-child(3){{width:82px;}}
14914 #scan-history-table col:nth-child(4){{width:82px;}}
14915 #scan-history-table col:nth-child(5){{width:90px;}}
14916 #scan-history-table col:nth-child(6){{width:90px;}}
14917 #scan-history-table col:nth-child(7){{width:88px;}}
14918 #scan-history-table col:nth-child(8){{width:150px;}}
14919 #scan-history-table td:nth-child(8){{overflow:visible!important;white-space:normal!important;}}
14920 .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;}}
14921 .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;}}
14922 .toolbar-divider{{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}}
14923 .toolbar-right{{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}}
14924 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
14925 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
14926 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
14927 .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;}}
14928 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
14929 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
14930 .watched-chip-rm:hover{{color:var(--oxide);}}
14931 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
14932 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
14933 .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
14934 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
14935 .mono{{font-family:ui-monospace,monospace;font-size:11px;}}
14936 a.run-link{{color:var(--accent-2);font-weight:700;text-decoration:none;}}
14937 a.run-link:hover{{text-decoration:underline;}}
14938 .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);}}
14939 .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);}}
14940 body.dark-theme .git-chip{{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}}
14941 .metric-num{{font-weight:700;color:var(--text);}}
14942 .metric-secondary{{font-size:11px;color:var(--muted);margin-top:2px;}}
14943 .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;}}
14944 .btn.primary{{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}}
14945 .btn.primary:hover{{opacity:.9;}}
14946 .rpt-btn{{min-width:58px;justify-content:center;}}
14947 .actions-cell{{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}}
14948 .report-cell{{overflow:visible!important;white-space:normal!important;}}
14949 .submod-details{{margin-top:6px;font-size:12px;color:var(--muted);}}
14950 .submod-details summary{{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}}
14951 .submod-details summary::-webkit-details-marker{{display:none;}}
14952 .submod-link-list{{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}}
14953 .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;}}
14954 .submod-view-btn:hover{{background:rgba(111,155,255,0.22);}}
14955 body.dark-theme .submod-view-btn{{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}}
14956 .chart-actions{{display:flex;justify-content:flex-end;gap:7px;margin-bottom:10px;}}
14957 .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;}}
14958 .export-btn:hover{{background:var(--line);}}
14959 .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
14960 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
14961 .site-footer a{{color:var(--muted);}}
14962 .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;}}
14963 .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;}}
14964 @keyframes spin-load{{to{{transform:rotate(360deg);}}}}
14965 /* Modal system (Retention Policy / Clean-up) */
14966 .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;}}
14967 @keyframes tr-fade{{from{{opacity:0;}}to{{opacity:1;}}}}
14968 .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);}}
14969 .tr-modal{{background:rgba(255,255,255,0.90);}}
14970 body.dark-theme .tr-modal{{background:rgba(38,28,23,0.90);}}
14971 @keyframes tr-pop{{from{{transform:translateY(14px) scale(.97);opacity:0;}}to{{transform:none;opacity:1;}}}}
14972 .tr-modal-head{{display:flex;align-items:center;gap:14px;padding:24px 30px 18px;border-bottom:1px solid var(--line);}}
14973 .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);}}
14974 .tr-modal-icon svg{{width:23px;height:23px;stroke:#fff;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}}
14975 .tr-modal-icon.danger{{background:linear-gradient(135deg,#d65a5a,#b23030);box-shadow:0 4px 12px rgba(178,48,48,0.32);}}
14976 .tr-modal-title{{font-size:21px;font-weight:900;letter-spacing:-.01em;color:var(--text);margin:0;line-height:1.15;}}
14977 .tr-modal-sub{{font-size:12.5px;color:var(--muted);margin:2px 0 0;line-height:1.4;}}
14978 .tr-modal-body{{padding:22px 30px;}}
14979 .tr-modal-foot{{display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;padding:18px 30px 24px;border-top:1px solid var(--line);}}
14980 .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;}}
14981 .tr-btn:hover{{transform:translateY(-1px);}}
14982 .tr-btn:active{{transform:translateY(0);}}
14983 .tr-btn:disabled{{opacity:.55;cursor:not-allowed;transform:none;}}
14984 .tr-btn svg{{width:15px;height:15px;stroke:currentColor;fill:none;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;}}
14985 .tr-btn-primary{{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;box-shadow:0 4px 14px rgba(184,80,40,0.28);}}
14986 .tr-btn-primary:hover{{box-shadow:0 7px 20px rgba(184,80,40,0.38);}}
14987 .tr-btn-secondary{{background:var(--surface-2);color:var(--text);border-color:var(--line-strong);}}
14988 .tr-btn-secondary:hover{{background:var(--line);}}
14989 .tr-btn-danger{{background:linear-gradient(135deg,#d65a5a,#b23030);color:#fff;box-shadow:0 4px 14px rgba(178,48,48,0.28);}}
14990 .tr-btn-danger:hover{{box-shadow:0 7px 20px rgba(178,48,48,0.4);}}
14991 </style>
14992</head>
14993<body>
14994 <div class="background-watermarks" aria-hidden="true">
14995 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14996 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14997 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14998 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
14999 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15000 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
15001 </div>
15002 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
15003 <div class="top-nav">
15004 <div class="top-nav-inner">
15005 <a class="brand" href="/">
15006 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
15007 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Trend report</div></div>
15008 </a>
15009 <div class="nav-right">
15010 <a class="nav-pill" href="/">Home</a>
15011 <div class="nav-dropdown">
15012 <a href="/view-reports" class="nav-dropdown-btn sx-8c38ef73" >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>
15013 <div class="nav-dropdown-menu">
15014 <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>
15015 </div>
15016 </div>
15017 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
15018 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
15019 <div class="nav-dropdown">
15020 <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>
15021 <div class="nav-dropdown-menu">
15022 <a href="/code-ownership"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>Code Ownership</a>
15023 <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>
15024 </div>
15025 </div>
15026 <div class="server-status-wrap" id="server-status-wrap">
15027 <div class="nav-pill server-online-pill" id="server-status-pill">
15028 <span class="status-dot" id="status-dot"></span>
15029 <span id="server-status-label">Server</span>
15030 <span class="sx-d60f2ef3" id="server-ping-ms" ></span>
15031 </div>
15032 <div class="server-status-tip">
15033 OxideSLOC is running — accessible on your network.
15034 <span class="sx-238af6bc" id="server-tip-ping" ></span>
15035 </div>
15036 </div>
15037 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
15038 <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>
15039 </button>
15040 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
15041 <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>
15042 <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>
15043 </button>
15044 </div>
15045 </div>
15046 </div>
15047
15048 <div class="page">
15049 {watched_dirs_html}
15050 <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
15051 <div class="scan-overlay-card">
15052 <div class="scan-spinner"></div>
15053 <div class="scan-overlay-text">Scanning folder…</div>
15054 <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
15055 </div>
15056 </div>
15057 <style nonce="{nonce}">
15058 .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);}}
15059 .scan-overlay.active{{display:flex;}}
15060 .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;}}
15061 .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;}}
15062 @keyframes scanSpin{{to{{transform:rotate(360deg);}}}}
15063 .scan-overlay-text{{font-size:15px;font-weight:800;color:var(--text);}}
15064 .scan-overlay-sub{{font-size:12px;color:var(--muted);line-height:1.5;}}
15065 </style>
15066 <div class="summary-strip" id="trend-stats"></div>
15067 <div class="panel">
15068 <div class="trend-header">
15069 <div class="trend-title-block">
15070 <h1>Trend Reports</h1>
15071 <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>
15072 <span class="chart-hint-inline">
15073 <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>
15074 Click a dot or row to view its full report · <span class="dot sx-52f7d121" ></span> regular scan <span class="dot sx-728e7e7c" ></span> tagged / release scan
15075 </span>
15076 </div>
15077 <div class="chart-actions">
15078 <button type="button" class="export-btn" id="retention-policy-btn" title="Configure automatic cleanup of old scan runs">
15079 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
15080 Retention Policy
15081 </button>
15082 <button type="button" class="export-btn" id="cleanup-runs-btn" title="Delete scans older than a chosen number of days">
15083 <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>
15084 Clean up old runs
15085 </button>
15086 <button type="button" class="export-btn" id="export-xlsx-btn" title="Download scan history as Excel workbook (.xlsx)">
15087 <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>
15088 Export Excel
15089 </button>
15090 <button type="button" class="export-btn" id="export-png-btn" title="Save chart as PNG image">
15091 <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>
15092 Export PNG
15093 </button>
15094 <button type="button" class="export-btn" id="export-pdf-btn" title="Open a print-ready PDF report (chart + summary + table)">
15095 <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>
15096 Export PDF
15097 </button>
15098 </div>
15099 </div>
15100
15101 <div class="controls-centered">
15102 <label>Project Root:
15103 <select class="chart-select" id="root-sel">
15104 <option value="">All projects</option>
15105 </select>
15106 </label>
15107 <label>Y Metric:
15108 <select class="chart-select" id="y-sel">
15109 <option value="code_lines">Code Lines</option>
15110 <option value="comment_lines">Comment Lines</option>
15111 <option value="blank_lines">Blank Lines</option>
15112 <option value="physical_lines">Physical Lines</option>
15113 <option value="files_analyzed">Files Analyzed</option>
15114 </select>
15115 </label>
15116 <label>X Axis:
15117 <select class="chart-select" id="x-sel">
15118 <option value="time">By Time</option>
15119 <option value="commit" selected>By Commit</option>
15120 <option value="release">By Release</option>
15121 <option value="tag">Tagged Commits</option>
15122 </select>
15123 </label>
15124 <label class="sx-d0466aa3" id="submodule-label" >Submodule:
15125 <select class="chart-select" id="sub-sel">
15126 <option value="">All (project total)</option>
15127 </select>
15128 </label>
15129 <label>Chart Size:
15130 <select class="chart-select" id="scale-sel">
15131 <option value="0.75">Compact</option>
15132 <option value="1.2" selected>Normal</option>
15133 <option value="1.38">Large</option>
15134 </select>
15135 </label>
15136 <button class="tr-expand-btn" id="tr-chart-fv-btn">⤢ Full View</button>
15137 </div>
15138
15139 <div id="chart-wrap" class="chart-wrap"><div class="loading-state"><div class="loading-spinner"></div>Loading scan history…</div></div>
15140 <div class="sx-fbff25e9" id="data-table-wrap" ></div>
15141 </div>
15142 </div>
15143
15144 <script nonce="{nonce}">
15145 (function() {{
15146 // Theme persistence
15147 var b = document.body;
15148 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
15149 var tgl = document.getElementById('theme-toggle');
15150 if (tgl) tgl.addEventListener('click', function() {{
15151 var d = b.classList.toggle('dark-theme');
15152 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
15153 }});
15154
15155 // Watermark randomizer
15156 (function() {{
15157 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
15158 if (!wms.length) return;
15159 var placed = [];
15160 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;}}
15161 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];}}
15162 var half=Math.floor(wms.length/2);
15163 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;}});
15164 }})();
15165
15166 // Code particles
15167 (function() {{
15168 var container = document.getElementById('code-particles');
15169 if (!container) return;
15170 var snippets = ['NUM sloc','fn analyze()','code_lines','0 mixed','blanks: NUM','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','NUM physical','files: NUM','NUM comments','cargo build','Ok(run)','Vec<GEN>','match lang','fn main()','.rs .go .py','sloc_core','render_html','NUM code','HashMap<GEN, GN2>','Option<GEN>','&str','author AUT','last_change','ratio 0.CPX','complexity CPX','tokens: NUM','?; // try','let owner','commits: NUM','#[tokio::test]','assert_eq!','PCT% cover','blame AUT','churn PCT%','HEAD~CPX','dedup PCT%','cyclomatic CPX','impl Iterator','.await','+NUM -NM2','ownership','Span<GEN>','pub struct','trait Lang','unsafe','mixed: NUM','anyhow::Result','fn detect()','PathBuf','+NUM loc','coverage PCT%','physical: NUM','ULOC NUM','dup PCT%','let mut run']; var __GT=['Author','String','Token','FileRecord','Lang','u32','usize','PathBuf','Commit','Line','Span','Metric','Record','u64','char','Utf8','Ratio','Blame']; var __AN=['nima','core','web','cli','git','lang','ci','bot','sloc'];
15171 for (var i = 0; i < 44; i++) {{
15172 (function(idx) {{
15173 var el = document.createElement('span');
15174 el.className = 'code-particle';
15175 el.textContent = snippets[idx % snippets.length].split('NUM').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('NM2').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('GEN').join(__GT[Math.floor(Math.random()*__GT.length)]).split('GN2').join(__GT[Math.floor(Math.random()*__GT.length)]).split('AUT').join(__AN[Math.floor(Math.random()*__AN.length)]).split('CPX').join(''+Math.floor(Math.random()*40+1)).split('PCT').join(''+Math.floor(Math.random()*44+1));
15176 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
15177 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
15178 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.108 + 0.072).toFixed(3);
15179 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';
15180 container.appendChild(el);
15181 }})(i);
15182 }}
15183 }})();
15184
15185 // Watched folder picker
15186 (function(){{
15187 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');}};
15188 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);
15189 }})();
15190 (function() {{
15191 var btn = document.getElementById('add-watched-btn');
15192 if (!btn) return;
15193 btn.addEventListener('click', function() {{
15194 fetch('/pick-directory?kind=reports')
15195 .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
15196 .then(function(data) {{
15197 if (!data.cancelled && data.selected_path) {{
15198 var form = document.createElement('form');
15199 form.method = 'POST';
15200 form.action = '/watched-dirs/add';
15201 var ri = document.createElement('input');
15202 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
15203 var fi = document.createElement('input');
15204 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
15205 form.appendChild(ri); form.appendChild(fi);
15206 document.body.appendChild(form);
15207 if (window.__scanOverlay) window.__scanOverlay();
15208 form.submit();
15209 }}
15210 }})
15211 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
15212 }});
15213 }})();
15214
15215 // Settings / color-scheme modal
15216 (function() {{
15217 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'}}];
15218 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);}});}}
15219 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
15220 var btn=document.getElementById('settings-btn');if(!btn)return;
15221 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
15222 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 class="sx-dec3b281" ><div class="settings-modal-label sx-c500155b" >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>';
15223 document.body.appendChild(m);
15224 var g=document.getElementById('scheme-grid');
15225 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);}});
15226 var cl=document.getElementById('settings-close');
15227 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);}});}})();
15228 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');}});
15229 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
15230 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
15231 }})();
15232 }})();
15233
15234 var ROOTS = {roots_json};
15235 var FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
15236 var COLS = ['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E'];
15237 var allData = [];
15238
15239 // Populate root selector
15240 var rootSel = document.getElementById('root-sel');
15241 ROOTS.forEach(function(r){{ var o=document.createElement('option');o.value=r;o.textContent=r;rootSel.appendChild(o); }});
15242
15243 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();}}
15244 function fmtFull(n){{return Number(n).toLocaleString();}}
15245 function esc(s){{ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }}
15246
15247 // Tooltip
15248 var tt = document.createElement('div');
15249 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);';
15250 document.body.appendChild(tt);
15251 function showTT(e,html){{tt.innerHTML=html;tt.style.display='block';moveTT(e);}}
15252 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';}}
15253 function hideTT(){{tt.style.display='none';}}
15254 window.addEventListener('blur',function(){{hideTT();}});
15255 document.addEventListener('visibilitychange',function(){{if(document.hidden)hideTT();}});
15256
15257 function statExact(compact, full){{
15258 return compact!==full?'<span class="stat-chip-exact">'+full+'</span>':'';
15259 }}
15260 function statVal(n){{
15261 var compact=fmt(n),full=fmtFull(n);return compact+statExact(compact,full);
15262 }}
15263
15264 function updateStats(data){{
15265 var statsEl=document.getElementById('trend-stats');
15266 if(!statsEl)return;
15267 if(!data||!data.length){{statsEl.innerHTML='';return;}}
15268 var yKey=document.getElementById('y-sel').value;
15269 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
15270 var sorted=data.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
15271 var firstVal=Number(sorted[0][yKey])||0,lastVal=Number(sorted[sorted.length-1][yKey])||0;
15272 var delta=lastVal-firstVal,sign=delta>=0?'+':'',cls=delta>=0?'stat-delta-up':'stat-delta-down';
15273 var absDelta=Math.abs(delta);
15274 var deltaCompact=fmt(absDelta),deltaFull=fmtFull(absDelta);
15275 var deltaExact=statExact(deltaCompact,deltaFull);
15276 var projs={{}};data.forEach(function(d){{projs[d.project_label]=1;}});
15277 statsEl.innerHTML=
15278 '<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>'+
15279 '<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>'+
15280 '<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>'+
15281 '<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>';
15282 }}
15283
15284 var subSel = document.getElementById('sub-sel');
15285 var subLabel = document.getElementById('submodule-label');
15286
15287 function populateSubmodules(root){{
15288 if(!subSel||!subLabel)return;
15289 while(subSel.options.length>1)subSel.remove(1);
15290 subSel.value='';
15291 var url='/api/metrics/submodules'+(root?'?root='+encodeURIComponent(root):'');
15292 fetch(url)
15293 .then(function(r){{return r.json();}})
15294 .then(function(subs){{
15295 if(!subs||!subs.length){{subLabel.style.display='none';return;}}
15296 subs.forEach(function(s){{
15297 var o=document.createElement('option');
15298 o.value=s.name;
15299 o.textContent=s.name+(s.relative_path&&s.relative_path!==s.name?' ('+s.relative_path+')':'');
15300 subSel.appendChild(o);
15301 }});
15302 subLabel.style.display='';
15303 }})
15304 .catch(function(){{subLabel.style.display='none';}});
15305 }}
15306
15307 var LOADING_HTML='<div class="loading-state"><div class="loading-spinner"></div>Loading scan history\u2026</div>';
15308
15309 function loadAndRender(){{
15310 var root = rootSel.value;
15311 var sub = subSel ? subSel.value : '';
15312 document.getElementById('chart-wrap').innerHTML=LOADING_HTML;
15313 document.getElementById('data-table-wrap').innerHTML='';
15314 var url = '/api/metrics/history?limit=100'
15315 + (root ? '&root='+encodeURIComponent(root) : '')
15316 + (sub ? '&submodule='+encodeURIComponent(sub) : '');
15317 fetch(url).then(function(r){{return r.json();}}).then(function(data){{
15318 allData = data;
15319 render(data);
15320 updateStats(data);
15321 }}).catch(function(){{
15322 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>';
15323 }});
15324 }}
15325
15326 function render(data){{
15327 var yKey = document.getElementById('y-sel').value;
15328 var xMode = document.getElementById('x-sel').value;
15329
15330 // Filter for tag/release mode
15331 var pts = data;
15332 if(xMode === 'tag') pts = data.filter(function(d){{return d.tags&&d.tags.length>0;}});
15333
15334 // Sort oldest-first for the line chart
15335 pts = pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
15336
15337 var wrap = document.getElementById('chart-wrap');
15338 if(!pts.length){{
15339 var emptyMsg = (xMode === 'tag')
15340 ? 'No scans found at exact tagged commits. Try <strong>By Release</strong> to see all scans labelled by their nearest ancestor release tag.'
15341 : 'No scan data found for the selected filters.';
15342 wrap.innerHTML='<div class="empty-state">'+emptyMsg+'</div>';
15343 renderTable([]);
15344 return;
15345 }}
15346
15347 var scaleEl=document.getElementById('scale-sel');
15348 var sc=scaleEl?parseFloat(scaleEl.value)||1:1;
15349 renderTrendInto(wrap, pts, yKey, xMode, sc);
15350 renderTable(pts, yKey);
15351 }}
15352
15353 // Draw the trend area+line chart (with points and tooltips) into `wrap` at scale `sc`.
15354 // Shared by the inline chart and the Full View modal so both render identically.
15355 function renderTrendInto(wrap, pts, yKey, xMode, sc){{
15356 // Fill the container width (like the Chart.js charts) instead of a fixed 900px
15357 // canvas centered with empty margins; Chart Size (sc) drives height + detail.
15358 var availW=Math.round(wrap.clientWidth||wrap.offsetWidth||900*sc);
15359 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;
15360 var maxY = Math.max.apply(null,pts.map(function(d){{return Number(d[yKey])||0;}}))||1;
15361
15362 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
15363
15364 var svg='<svg class="sx-c0edbe3d" viewBox="0 0 '+W+' '+H+'" width="'+W+'" height="'+H+'" xmlns="http://www.w3.org/2000/svg">';
15365 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>';
15366
15367 var fs=Math.round(10*sc),fsS=Math.round(9*sc),fsL=Math.round(11*sc);
15368
15369 // Grid + Y axis ticks
15370 for(var ti=0;ti<=5;ti++){{
15371 var gy=PT+CH-Math.round(ti/5*CH);
15372 var gv=Math.round(ti/5*maxY);
15373 svg+='<line x1="'+PL+'" y1="'+gy+'" x2="'+(PL+CW)+'" y2="'+gy+'" stroke="#e6d0bf" stroke-width="1"/>';
15374 svg+='<text x="'+(PL-6)+'" y="'+(gy+4)+'" text-anchor="end" font-family="'+FONT+'" font-size="'+fs+'" fill="#7b675b">'+fmtFull(gv)+'</text>';
15375 }}
15376
15377 // X axis labels (every N-th point to avoid crowding)
15378 var labelEvery=Math.max(1,Math.ceil(pts.length/10));
15379 pts.forEach(function(d,i){{
15380 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
15381 if(i%labelEvery===0||i===pts.length-1){{
15382 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)));
15383 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>';
15384 }}
15385 }});
15386
15387 // Axis label
15388 var xAxisLabel=xMode==='time'?'Scan Date':(xMode==='commit'?'Commit':(xMode==='release'?'Release':'Tag'));
15389 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>';
15390 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>';
15391
15392 // Area fill + line path
15393 var pathD='';
15394 pts.forEach(function(d,i){{
15395 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
15396 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
15397 pathD+=(i===0?'M':'L')+x+','+y;
15398 }});
15399 if(pts.length>1){{
15400 var x0=PL,xN=PL+Math.round((pts.length-1)/(Math.max(pts.length-1,1))*CW);
15401 svg+='<path d="M'+x0+','+(PT+CH)+' '+pathD.substring(1)+' L'+xN+','+(PT+CH)+'Z" fill="url(#areaFill)" pointer-events="none"/>';
15402 }}
15403 svg+='<path d="'+pathD+'" fill="none" stroke="#C45C10" stroke-width="'+(2+sc)+'" stroke-linejoin="round" stroke-linecap="round"/>';
15404
15405 // Data points (clickable) + permanent value labels
15406 var showLabels = pts.length <= 40;
15407 var labelEveryN = pts.length > 20 ? 2 : 1;
15408 pts.forEach(function(d,i){{
15409 var x=PL+Math.round(i/(Math.max(pts.length-1,1))*CW);
15410 var y=PT+CH-Math.round((Number(d[yKey])||0)/maxY*CH);
15411 var hasTags=d.tags&&d.tags.length>0;
15412 var isReleasePoint=hasTags||(xMode==='release'&&d.nearest_tag);
15413 var r=Math.round((hasTags?7:5)*Math.sqrt(sc));
15414 svg+='<circle class="trend-pt sx-83ac1cee" cx="'+x+'" cy="'+y+'" r="'+r+'" fill="'+(isReleasePoint?'#4472C4':'#C45C10')+'" stroke="white" stroke-width="2" data-idx="'+i+'"/>';
15415 if(showLabels && i%labelEveryN===0){{
15416 var lx=x, ly=y-r-5;
15417 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>';
15418 }}
15419 }});
15420
15421 svg+='</svg>';
15422 wrap.innerHTML=svg;
15423
15424 // Pixel Y of the line at chart-space x (straight segments → linear interpolation).
15425 function lineYAt(mx){{
15426 var n=pts.length;
15427 if(n===0)return PT+CH;
15428 if(n===1)return PT+CH-Math.round((Number(pts[0][yKey])||0)/maxY*CH);
15429 var fx=(mx-PL)/Math.max(CW,1)*(n-1);
15430 if(fx<0)fx=0; if(fx>n-1)fx=n-1;
15431 var i0=Math.floor(fx),i1=Math.min(i0+1,n-1),t=fx-i0;
15432 var y0=PT+CH-(Number(pts[i0][yKey])||0)/maxY*CH;
15433 var y1=PT+CH-(Number(pts[i1][yKey])||0)/maxY*CH;
15434 return y0+t*(y1-y0);
15435 }}
15436
15437 // SVG-level mousemove: show the value tooltip only when the pointer is over the
15438 // gradient fill (inside the chart and at/below the line) — never in the empty
15439 // space above the line. Cursor follows the same rule.
15440 (function(){{
15441 var svgEl=wrap.querySelector('svg');
15442 if(!svgEl)return;
15443 svgEl.addEventListener('mousemove',function(e){{
15444 if(e.target&&e.target.classList&&e.target.classList.contains('trend-pt'))return; // circle handles its own tooltip
15445 var rect=svgEl.getBoundingClientRect();
15446 var scaleX=W/Math.max(rect.width,1);
15447 var scaleY=H/Math.max(rect.height,1);
15448 var mouseX=(e.clientX-rect.left)*scaleX;
15449 var mouseY=(e.clientY-rect.top)*scaleY;
15450 var ly=lineYAt(mouseX);
15451 if(mouseX<PL||mouseX>PL+CW||mouseY<ly-6*sc||mouseY>PT+CH){{hideTT();svgEl.style.cursor='default';return;}}
15452 svgEl.style.cursor='pointer';
15453 var idx=Math.max(0,Math.min(pts.length-1,Math.round((mouseX-PL)/Math.max(CW,1)*(pts.length-1))));
15454 var d=pts[idx];
15455 var val=Number(d[yKey]);
15456 var lbl=xMode==='commit'&&d.commit?d.commit.substring(0,7):d.timestamp.substring(0,10);
15457 showTT(e,
15458 '<strong class="sx-b907acdf" >'+esc(lbl)+'</strong>'+
15459 (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(val)+'</strong>'+
15460 '<br><span class="sx-1603cce1" >'+d.timestamp.substring(0,10)+'</span>'
15461 );
15462 }});
15463 svgEl.addEventListener('mouseleave',function(){{hideTT();svgEl.style.cursor='default';}});
15464 }})();
15465
15466 // Attach point tooltips
15467 wrap.querySelectorAll('.trend-pt').forEach(function(c){{
15468 c.addEventListener('mouseover',function(e){{
15469 var d=pts[parseInt(this.dataset.idx)];
15470 var tagsHtml=d.tags&&d.tags.length?'<br>Tags: '+d.tags.map(function(t){{return'<span class="sx-40777e33" >'+esc(t)+'</span>';}}).join(''):'';
15471 var nearestHtml=d.nearest_tag?'<br>Nearest release: <span class="sx-89081a19" >'+esc(d.nearest_tag)+'</span>':'';
15472 showTT(e,
15473 '<strong class="sx-b907acdf" >'+esc(d.project_label)+'</strong>'+
15474 (Y_LABELS[yKey]||yKey)+': <strong>'+fmtFull(Number(d[yKey]))+'</strong><br>'+
15475 'Date: '+d.timestamp.substring(0,10)+(d.commit?'<br>Commit: <code>'+esc(d.commit.substring(0,12))+'</code>':'')+
15476 (d.branch?'<br>Branch: '+esc(d.branch):'')+tagsHtml+nearestHtml
15477 );
15478 this.setAttribute('r','8');
15479 }});
15480 c.addEventListener('mouseout',function(){{hideTT();var _d=pts[parseInt(this.dataset.idx)];this.setAttribute('r',(_d.tags&&_d.tags.length)?'7':'5');}});
15481 c.addEventListener('mousemove',moveTT);
15482 c.addEventListener('click',function(){{
15483 var d=pts[parseInt(this.dataset.idx)];
15484 if(d.html_url) window.open(d.html_url,'_blank');
15485 }});
15486 }});
15487 }}
15488
15489 var shData=[], shSortCol=null, shSortOrder='asc', shPage=1, shPerPage=25;
15490 var shProjFilter='', shBranchFilter='';
15491
15492 function fmtPST(isoStr){{
15493 if(!isoStr)return'';
15494 var d=new Date(isoStr);
15495 if(isNaN(d.getTime()))return isoStr.substring(0,16).replace('T',' ');
15496 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);}}
15497 function p(n){{return n<10?'0'+n:String(n);}}
15498 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++;}}}}
15499 var yr=d.getUTCFullYear();
15500 var dstStart=new Date(nthWeekdaySun(yr,2,2).getTime()+10*3600*1000);
15501 var dstEnd=new Date(nthWeekdaySun(yr,10,1).getTime()+9*3600*1000);
15502 var isDST=d>=dstStart&&d<dstEnd;
15503 var off=isDST?-7*3600*1000:-8*3600*1000;
15504 var lbl=isDST?'PDT':'PST';
15505 var loc=new Date(d.getTime()+off);
15506 return loc.getUTCFullYear()+'-'+p(loc.getUTCMonth()+1)+'-'+p(loc.getUTCDate())+' '+p(loc.getUTCHours())+':'+p(loc.getUTCMinutes())+' '+lbl;
15507 }}
15508
15509 function getShRows(){{
15510 var proj=shProjFilter.toLowerCase().trim();
15511 var branch=shBranchFilter;
15512 return shData.filter(function(d){{
15513 if(proj&&!(d.project_label||'').toLowerCase().includes(proj))return false;
15514 if(branch&&(d.branch||'')!==branch)return false;
15515 return true;
15516 }});
15517 }}
15518
15519 function renderShPage(){{
15520 var filtered=getShRows();
15521 if(shSortCol){{
15522 filtered.sort(function(a,b){{
15523 var va,vb;
15524 if(shSortCol==='metric'){{va=a._metricVal||0;vb=b._metricVal||0;return shSortOrder==='asc'?va-vb:vb-va;}}
15525 if(shSortCol==='timestamp'){{va=a.timestamp||'';vb=b.timestamp||'';}}
15526 else if(shSortCol==='project'){{va=(a.project_label||'').toLowerCase();vb=(b.project_label||'').toLowerCase();}}
15527 else if(shSortCol==='branch'){{va=(a.branch||'').toLowerCase();vb=(b.branch||'').toLowerCase();}}
15528 else{{va=String(a[shSortCol]||'').toLowerCase();vb=String(b[shSortCol]||'').toLowerCase();}}
15529 return shSortOrder==='asc'?(va<vb?-1:va>vb?1:0):(va<vb?1:va>vb?-1:0);
15530 }});
15531 }}
15532 var total=filtered.length,totalPages=Math.max(1,Math.ceil(total/shPerPage));
15533 shPage=Math.min(shPage,totalPages);
15534 var start=(shPage-1)*shPerPage,end=Math.min(start+shPerPage,total);
15535 var visible=filtered.slice(start,end);
15536 var tbody=document.getElementById('sh-tbody');
15537 if(!tbody)return;
15538 tbody.innerHTML=visible.map(function(d){{
15539 var tsHtml=esc(fmtPST(d.timestamp));
15540 var tags=(d.tags&&d.tags.length)?d.tags.map(function(t){{return'<span class="tag-chip">'+esc(t)+'</span>';}}).join(''):'<span class="sx-eac76940" >—</span>';
15541 var commitHtml=d.commit?'<span class="git-chip" title="'+esc(d.commit)+'">'+esc(d.commit.substring(0,7))+'</span>':'<span class="sx-eac76940" >—</span>';
15542 var branchHtml=d.branch?'<span class="git-chip">'+esc(d.branch)+'</span>':'<span class="sx-eac76940" >—</span>';
15543 var runIdHtml=d.run_id_short?'<span class="run-id-chip">'+esc(d.run_id_short)+'</span>':'—';
15544 var metricHtml='<span class="metric-num">'+fmtFull(d._metricVal)+'</span>';
15545 var reportCell='';
15546 if(d.html_url){{
15547 reportCell+='<div class="actions-cell"><a class="btn primary rpt-btn" href="'+esc(d.html_url)+'" target="_blank" rel="noopener">View</a>';
15548 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>';}}
15549 reportCell+='</div>';
15550 }}else{{reportCell='<span class="sx-e7c11689" >—</span>';}}
15551 if(d.submodule_links&&d.submodule_links.length){{
15552 reportCell+='<details class="submod-details"><summary>↳ '+d.submodule_links.length+' submodule(s)</summary><div class="submod-link-list">';
15553 d.submodule_links.forEach(function(s){{reportCell+='<a href="'+esc(s.url)+'" target="_blank" rel="noopener" class="submod-view-btn">'+esc(s.name)+'</a>';}});
15554 reportCell+='</div></details>';
15555 }}
15556 return '<tr>'
15557 +'<td>'+tsHtml+'</td>'
15558 +'<td title="'+esc(d.project_label)+'">'+esc(d.project_label)+'</td>'
15559 +'<td>'+runIdHtml+'</td>'
15560 +'<td>'+commitHtml+'</td>'
15561 +'<td>'+branchHtml+'</td>'
15562 +'<td>'+tags+'</td>'
15563 +'<td class="num">'+metricHtml+'</td>'
15564 +'<td class="report-cell">'+reportCell+'</td>'
15565 +'</tr>';
15566 }}).join('');
15567 var pgRange=document.getElementById('sh-pg-range');
15568 if(pgRange)pgRange.textContent=total?'Showing '+(start+1)+'\u2013'+end+' of '+total:'No results';
15569 var pgInfo=document.getElementById('sh-pg-info');
15570 if(pgInfo)pgInfo.textContent='Page '+shPage+' of '+totalPages;
15571 var pgBtns=document.getElementById('sh-pg-btns');
15572 if(pgBtns){{
15573 pgBtns.innerHTML='';
15574 function mkPgBtn(lbl,pg,active,disabled){{
15575 var b=document.createElement('button');b.className='pg-btn'+(active?' active':'');b.textContent=lbl;b.disabled=disabled;
15576 if(!disabled)b.addEventListener('click',function(){{shPage=pg;renderShPage();}});
15577 return b;
15578 }}
15579 pgBtns.appendChild(mkPgBtn('\u2039',shPage-1,false,shPage===1));
15580 var ws=Math.max(1,shPage-2),we=Math.min(totalPages,ws+4);ws=Math.max(1,we-4);
15581 for(var pg=ws;pg<=we;pg++)pgBtns.appendChild(mkPgBtn(String(pg),pg,pg===shPage,false));
15582 pgBtns.appendChild(mkPgBtn('\u203a',shPage+1,false,shPage===totalPages));
15583 }}
15584 }}
15585
15586 function wireTableBehavior(){{
15587 var pf=document.getElementById('sh-proj-filter');
15588 if(pf){{pf.value=shProjFilter;pf.addEventListener('input',function(){{shProjFilter=this.value;shPage=1;renderShPage();}});}}
15589 var bf=document.getElementById('sh-branch-filter');
15590 if(bf){{bf.value=shBranchFilter;bf.addEventListener('change',function(){{shBranchFilter=this.value;shPage=1;renderShPage();}});}}
15591 var rb=document.getElementById('sh-reset-btn');
15592 if(rb)rb.addEventListener('click',function(){{
15593 shProjFilter='';shBranchFilter='';shSortCol=null;shSortOrder='asc';shPage=1;
15594 var pf2=document.getElementById('sh-proj-filter');if(pf2)pf2.value='';
15595 var bf2=document.getElementById('sh-branch-filter');if(bf2)bf2.value='';
15596 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');}});
15597 renderShPage();
15598 }});
15599 var pps=document.getElementById('sh-per-page');
15600 if(pps)pps.addEventListener('change',function(){{shPerPage=parseInt(this.value,10)||25;shPage=1;renderShPage();}});
15601 var ths=Array.prototype.slice.call(document.querySelectorAll('#sh-thead .sortable'));
15602 ths.forEach(function(th){{
15603 th.addEventListener('click',function(e){{
15604 if(e.target.classList.contains('col-resize-handle'))return;
15605 var col=th.dataset.col;
15606 if(shSortCol===col){{shSortOrder=shSortOrder==='asc'?'desc':'asc';}}else{{shSortCol=col;shSortOrder='asc';}}
15607 ths.forEach(function(t){{var si=t.querySelector('.sort-icon');if(si)si.textContent='\u2195';t.classList.remove('sort-asc','sort-desc');}});
15608 th.classList.add('sort-'+shSortOrder);
15609 var si=th.querySelector('.sort-icon');if(si)si.textContent=shSortOrder==='asc'?'\u2191':'\u2193';
15610 shPage=1;renderShPage();
15611 }});
15612 }});
15613 var table=document.getElementById('scan-history-table');
15614 if(!table)return;
15615 var cols=Array.prototype.slice.call(table.querySelectorAll('col'));
15616 var allThs=Array.prototype.slice.call(table.querySelectorAll('#sh-thead th'));
15617 allThs.forEach(function(th,i){{
15618 var handle=th.querySelector('.col-resize-handle');
15619 if(!handle||!cols[i])return;
15620 var startX,startW;
15621 handle.addEventListener('mousedown',function(e){{
15622 e.stopPropagation();e.preventDefault();
15623 startX=e.clientX;startW=cols[i].offsetWidth||th.offsetWidth;
15624 handle.classList.add('dragging');
15625 function onMove(ev){{cols[i].style.width=Math.max(40,startW+ev.clientX-startX)+'px';}}
15626 function onUp(){{handle.classList.remove('dragging');document.removeEventListener('mousemove',onMove);document.removeEventListener('mouseup',onUp);}}
15627 document.addEventListener('mousemove',onMove);
15628 document.addEventListener('mouseup',onUp);
15629 }});
15630 }});
15631 }}
15632
15633 function renderTable(pts, yKey){{
15634 var Y_LABELS={{code_lines:'Code Lines',comment_lines:'Comments',blank_lines:'Blanks',physical_lines:'Physical',files_analyzed:'Files'}};
15635 var wrap=document.getElementById('data-table-wrap');
15636 if(!pts||!pts.length){{wrap.innerHTML='';return;}}
15637 var yLabel=Y_LABELS[yKey]||yKey||'';
15638 shData=pts.slice().reverse();
15639 shSortCol=null;shSortOrder='asc';shPage=1;shProjFilter='';shBranchFilter='';
15640 shData.forEach(function(d){{d._metricVal=Number(d[yKey])||0;}});
15641 var branches={{}};
15642 shData.forEach(function(d){{if(d.branch)branches[d.branch]=true;}});
15643 var branchOpts='<option value="">All branches</option>';
15644 Object.keys(branches).sort().forEach(function(b){{branchOpts+='<option value="'+esc(b)+'">'+esc(b)+'</option>';}});
15645 wrap.innerHTML=
15646 '<div class="chart-section-header">SCAN HISTORY</div>'+
15647 '<div class="filter-row">'+
15648 '<input class="filter-input" id="sh-proj-filter" type="text" placeholder="Filter by path or name\u2026">'+
15649 '<select class="filter-select" id="sh-branch-filter">'+branchOpts+'</select>'+
15650 '<button type="button" class="btn" id="sh-reset-btn">\u21bb Reset view</button>'+
15651 '</div>'+
15652 '<div class="table-wrap">'+
15653 '<table id="scan-history-table" class="data-table">'+
15654 '<colgroup><col><col><col><col><col><col><col><col></colgroup>'+
15655 '<thead><tr id="sh-thead">'+
15656 '<th class="sortable" data-col="timestamp" data-type="str">Scan Date<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
15657 '<th class="sortable" data-col="project" data-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
15658 '<th>Run ID<div class="col-resize-handle"></div></th>'+
15659 '<th>Commit<div class="col-resize-handle"></div></th>'+
15660 '<th class="sortable" data-col="branch" data-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
15661 '<th>Tags<div class="col-resize-handle"></div></th>'+
15662 '<th class="sortable num" data-col="metric" data-type="num">'+esc(yLabel)+'<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>'+
15663 '<th>Report<div class="col-resize-handle"></div></th>'+
15664 '</tr></thead>'+
15665 '<tbody id="sh-tbody"></tbody>'+
15666 '</table>'+
15667 '</div>'+
15668 '<div class="pagination">'+
15669 '<span class="pagination-info" id="sh-pg-info"></span>'+
15670 '<div class="pagination-btns" id="sh-pg-btns"></div>'+
15671 '<div class="sx-68443324" >'+
15672 '<span class="sx-68694475" >Show</span>'+
15673 '<select class="filter-select" id="sh-per-page">'+
15674 '<option value="10">10 per page</option>'+
15675 '<option value="25" selected>25 per page</option>'+
15676 '<option value="50">50 per page</option>'+
15677 '<option value="100">100 per page</option>'+
15678 '</select>'+
15679 '<span class="sx-68694475" id="sh-pg-range"></span>'+
15680 '</div>'+
15681 '</div>';
15682 wireTableBehavior();
15683 renderShPage();
15684 }}
15685
15686 function exportXLSX(){{
15687 if(!allData||!allData.length){{alert('No data to export yet.');return;}}
15688 var xbtn=document.getElementById('export-xlsx-btn');
15689 var xorig=xbtn?xbtn.innerHTML:'';
15690 if(xbtn){{xbtn.disabled=true;xbtn.textContent='Preparing\u2026';}}
15691 var root=rootSel.value;
15692 var url='/api/metrics/churn?limit=500'+(root?'&root='+encodeURIComponent(root):'');
15693 fetch(url).then(function(r){{return r.ok?r.json():[];}}).catch(function(){{return [];}}).then(function(churn){{
15694 var cm={{}};(churn||[]).forEach(function(c){{cm[c.run_id]=c;}});
15695 buildAndDownloadXLSX(cm);
15696 }}).finally(function(){{if(xbtn){{xbtn.disabled=false;xbtn.innerHTML=xorig;}}}});
15697 }}
15698
15699 function buildAndDownloadXLSX(churnMap){{
15700 var sorted=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
15701 // X-axis is the git commit. Dedupe by project+commit, keeping the latest scan
15702 // (sorted is newest-first), so a given project/commit appears at most once.
15703 var seenPC={{}},dedup=[];
15704 sorted.forEach(function(d){{var k=(d.project_label||'')+'|'+(d.commit||'');if(!seenPC[k]){{seenPC[k]=1;dedup.push(d);}}}});
15705 var s1H=['Date','Project','Commit','Branch','Tags','Code Lines','Comment Lines','Blank Lines','Physical Lines','Files Analyzed','Report URL','Added','Deleted','Modified','Unmodified','Total'];
15706 var s1R=dedup.map(function(d){{
15707 var c=churnMap[d.run_id]||{{}};
15708 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)];
15709 }});
15710 var pm={{}};
15711 dedup.forEach(function(d){{var p=d.project_label||'Unknown';if(!pm[p])pm[p]=[];pm[p].push(d);}});
15712 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'];
15713 var s2R=Object.keys(pm).map(function(p){{
15714 var sc=pm[p].slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
15715 var lat=sc[sc.length-1],fst=sc[0];
15716 var codes=sc.map(function(s){{return+(s.code_lines)||0;}});
15717 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);
15718 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];
15719 }});
15720 var buf=buildXLSX([{{name:'Scan History',headers:s1H,rows:s1R}},{{name:'By Project',headers:s2H,rows:s2R}},{{name:'Focus Chart',headers:[],rows:[]}}],s1R,s2R);
15721 var a=document.createElement('a');a.download='oxide-sloc-trend.xlsx';
15722 a.href=URL.createObjectURL(new Blob([buf],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
15723 a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
15724 }}
15725
15726 function buildXLSX(sheets,chartRows,chartRows2){{
15727 function s2b(s){{return new TextEncoder().encode(s);}}
15728 function xe(s){{return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}}
15729 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;}}
15730 function crc32(d){{
15731 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;}}}}
15732 var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
15733 }}
15734 function buildSheet(hdr,rows,drawRid,withCtrl){{
15735 var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
15736 if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
15737 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'><sheetData>';
15738 x+='<row r="1">';
15739 hdr.forEach(function(h,ci){{x+='<c r="'+col2l(ci+1)+'1" t="inlineStr" s="1"><is><t>'+xe(h)+'</t></is></c>';}});
15740 if(withCtrl){{x+='<c r="Q1" t="inlineStr" s="1"><is><t>Selected Metric (set on Focus Chart tab)</t></is></c>';}}
15741 x+='</row>';
15742 rows.forEach(function(row,ri){{
15743 var rn=ri+2;
15744 x+='<row r="'+rn+'">';
15745 row.forEach(function(cell,ci){{
15746 var addr=col2l(ci+1)+rn;
15747 if(typeof cell==='number'){{x+='<c r="'+addr+'"><v>'+cell+'</v></c>';}}
15748 else{{x+='<c r="'+addr+'" t="inlineStr"><is><t>'+xe(String(cell))+'</t></is></c>';}}
15749 }});
15750 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>";}}
15751 x+='</row>';
15752 }});
15753 x+='</sheetData>';
15754 if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
15755 return x+'</worksheet>';
15756 }}
15757 function buildChartXML(rows){{
15758 var sn="'Scan History'";
15759 var nr=rows.length,er=nr+1;
15760 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'}}];
15761 var catCol='C',catIdx=2;
15762 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
15763 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">';
15764 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart>';
15765 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>';
15766 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
15767 sd.forEach(function(s,i){{
15768 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
15769 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>';
15770 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
15771 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>';
15772 x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
15773 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
15774 x+='</c:strCache></c:strRef></c:cat>';
15775 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+'"/>';
15776 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
15777 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
15778 }});
15779 x+='<c:axId val="1"/><c:axId val="2"/></c:lineChart>';
15780 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>';
15781 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>';
15782 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
15783 return x;
15784 }}
15785 function buildChartXML2(rows){{
15786 var sn="'By Project'";
15787 var nr=rows.length,er=nr+1;
15788 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'}}];
15789 var catCol='A',catIdx=0;
15790 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
15791 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">';
15792 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart>';
15793 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>';
15794 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
15795 sd.forEach(function(s,i){{
15796 x+='<c:ser><c:idx val="'+i+'"/><c:order val="'+i+'"/>';
15797 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>';
15798 x+='<c:spPr><a:ln w="25400"><a:solidFill><a:srgbClr val="'+s.clr+'"/></a:solidFill></a:ln></c:spPr>';
15799 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>';
15800 x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
15801 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
15802 x+='</c:strCache></c:strRef></c:cat>';
15803 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+'"/>';
15804 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[s.di])+'</c:v></c:pt>';}});
15805 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
15806 }});
15807 x+='<c:axId val="3"/><c:axId val="4"/></c:lineChart>';
15808 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>';
15809 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>';
15810 x+='</c:plotArea><c:legend><c:legendPos val="b"/><c:overlay val="0"/></c:legend><c:plotVisOnly val="1"/></c:chart></c:chartSpace>';
15811 return x;
15812 }}
15813 function buildChartXML3(rows){{
15814 var sn="'Scan History'";
15815 var nr=rows.length,er=nr+1;
15816 var catCol='C',catIdx=2;
15817 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
15818 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">';
15819 x+='<c:date1904 val="0"/><c:lang val="en-US"/><c:chart><c:autoTitleDeleted val="0"/><c:plotArea>';
15820 x+='<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>';
15821 x+='<c:ser><c:idx val="0"/><c:order val="0"/>';
15822 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>";
15823 x+='<c:spPr><a:ln w="31750"><a:solidFill><a:srgbClr val="C45C10"/></a:solidFill></a:ln></c:spPr>';
15824 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>';
15825 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>';
15826 x+='<c:cat><c:strRef><c:f>'+sn+'!$'+catCol+'$2:$'+catCol+'$'+er+'</c:f><c:strCache><c:ptCount val="'+nr+'"/>';
15827 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+xe(String(r[catIdx]))+'</c:v></c:pt>';}});
15828 x+='</c:strCache></c:strRef></c:cat>';
15829 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+'"/>';
15830 rows.forEach(function(r,ri){{x+='<c:pt idx="'+ri+'"><c:v>'+Number(r[5])+'</c:v></c:pt>';}});
15831 x+='</c:numCache></c:numRef></c:val><c:smooth val="0"/></c:ser>';
15832 x+='<c:axId val="5"/><c:axId val="6"/></c:lineChart>';
15833 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>';
15834 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>';
15835 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>';
15836 return x;
15837 }}
15838 function buildFocusSheet(drawRid){{
15839 var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
15840 if(drawRid){{ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';}}
15841 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>';
15842 x+='<cols><col min="1" max="1" width="11" customWidth="1"/><col min="2" max="2" width="20" customWidth="1"/></cols>';
15843 x+='<sheetData><row r="1">';
15844 x+='<c r="A1" t="inlineStr" s="1"><is><t>Metric:</t></is></c>';
15845 x+='<c r="B1" t="inlineStr"><is><t>Code Lines</t></is></c>';
15846 x+='<c r="D1" t="inlineStr"><is><t>← Pick a metric from the dropdown to update the chart below</t></is></c>';
15847 x+='</row></sheetData>';
15848 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>';
15849 if(drawRid){{x+='<drawing r:id="'+drawRid+'"/>';}}
15850 return x+'</worksheet>';
15851 }}
15852 var hasChart=!!(chartRows&&chartRows.length);
15853 var nr=hasChart?chartRows.length:0;
15854 var hasChart2=!!(chartRows2&&chartRows2.length);
15855 var nr2=hasChart2?chartRows2.length:0;
15856 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>';
15857 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"/>';
15858 sheets.forEach(function(s,i){{ct+='<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}});
15859 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"/>';}}
15860 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"/>';}}
15861 ct+='<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/></Types>';
15862 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>';
15863 var wbr='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">';
15864 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"/>';}});
15865 wbr+='<Relationship Id="rId'+(sheets.length+1)+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>';
15866 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>';
15867 sheets.forEach(function(s,i){{wbx+='<sheet name="'+xe(s.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}});
15868 wbx+='</sheets></workbook>';
15869 var files=[
15870 {{name:'[Content_Types].xml',data:s2b(ct)}},
15871 {{name:'_rels/.rels',data:s2b(dotrels)}},
15872 {{name:'xl/workbook.xml',data:s2b(wbx)}},
15873 {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
15874 {{name:'xl/styles.xml',data:s2b(styl)}}
15875 ];
15876 // Chart embedded directly in Scan History (sheet1); By Project is plain
15877 sheets.forEach(function(s,i){{
15878 var sx;
15879 if(s.name==='Focus Chart'){{sx=buildFocusSheet(hasChart?'rId1':null);}}
15880 else{{sx=buildSheet(s.headers,s.rows,(hasChart&&i===0)?'rId1':(hasChart2&&i===1)?'rId1':null,(hasChart&&i===0));}}
15881 files.push({{name:'xl/worksheets/sheet'+(i+1)+'.xml',data:s2b(sx)}});
15882 }});
15883 if(hasChart){{
15884 var fromRow=nr+4,toRow=nr+34;
15885 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>')}});
15886 var drx='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
15887 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">';
15888 drx+='<xdr:twoCellAnchor editAs="twoCell">';
15889 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>';
15890 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>';
15891 drx+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="2" name="Chart 1"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
15892 drx+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
15893 drx+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
15894 drx+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
15895 drx+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
15896 files.push({{name:'xl/drawings/drawing1.xml',data:s2b(drx)}});
15897 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>')}});
15898 files.push({{name:'xl/charts/chart1.xml',data:s2b(buildChartXML(chartRows))}});
15899 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>')}});
15900 var drx3='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
15901 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">';
15902 drx3+='<xdr:twoCellAnchor editAs="twoCell">';
15903 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>';
15904 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>';
15905 drx3+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="4" name="Chart 3"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
15906 drx3+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
15907 drx3+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
15908 drx3+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
15909 drx3+='</a:graphicData></a:graphic></xdr:graphicFrame><xdr:clientData/></xdr:twoCellAnchor></xdr:wsDr>';
15910 files.push({{name:'xl/drawings/drawing3.xml',data:s2b(drx3)}});
15911 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>')}});
15912 files.push({{name:'xl/charts/chart3.xml',data:s2b(buildChartXML3(chartRows))}});
15913 }}
15914 if(hasChart2){{
15915 var fromRow2=nr2+4,toRow2=nr2+36;
15916 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>')}});
15917 var drx2='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
15918 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">';
15919 drx2+='<xdr:twoCellAnchor editAs="twoCell">';
15920 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>';
15921 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>';
15922 drx2+='<xdr:graphicFrame macro=""><xdr:nvGraphicFramePr><xdr:cNvPr id="3" name="Chart 2"/><xdr:cNvGraphicFramePr/></xdr:nvGraphicFramePr>';
15923 drx2+='<xdr:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/></xdr:xfrm>';
15924 drx2+='<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">';
15925 drx2+='<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="rId1"/>';
15926 drx2+='<\/a:graphicData><\/a:graphic><\/xdr:graphicFrame><xdr:clientData\/><\/xdr:twoCellAnchor><\/xdr:wsDr>';
15927 files.push({{name:'xl/drawings/drawing2.xml',data:s2b(drx2)}});
15928 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>')}});
15929 files.push({{name:'xl/charts/chart2.xml',data:s2b(buildChartXML2(chartRows2))}});
15930 }}
15931 var parts=[],offsets=[],total=0;
15932 files.forEach(function(f){{
15933 offsets.push(total);
15934 var nb=s2b(f.name),crc=crc32(f.data);
15935 var h=new DataView(new ArrayBuffer(30+nb.length));
15936 h.setUint32(0,0x04034B50,true);h.setUint16(4,20,true);h.setUint16(6,0,true);h.setUint16(8,0,true);
15937 h.setUint16(10,0,true);h.setUint16(12,0,true);h.setUint32(14,crc,true);
15938 h.setUint32(18,f.data.length,true);h.setUint32(22,f.data.length,true);
15939 h.setUint16(26,nb.length,true);h.setUint16(28,0,true);
15940 for(var i=0;i<nb.length;i++)h.setUint8(30+i,nb[i]);
15941 parts.push(new Uint8Array(h.buffer));parts.push(f.data);
15942 total+=30+nb.length+f.data.length;
15943 }});
15944 var cdStart=total;
15945 files.forEach(function(f,fi){{
15946 var nb=s2b(f.name),crc=crc32(f.data);
15947 var cd=new DataView(new ArrayBuffer(46+nb.length));
15948 cd.setUint32(0,0x02014B50,true);cd.setUint16(4,20,true);cd.setUint16(6,20,true);
15949 cd.setUint16(8,0,true);cd.setUint16(10,0,true);cd.setUint16(12,0,true);cd.setUint16(14,0,true);
15950 cd.setUint32(16,crc,true);cd.setUint32(20,f.data.length,true);cd.setUint32(24,f.data.length,true);
15951 cd.setUint16(28,nb.length,true);cd.setUint16(30,0,true);cd.setUint16(32,0,true);
15952 cd.setUint16(34,0,true);cd.setUint16(36,0,true);cd.setUint32(38,0,true);cd.setUint32(42,offsets[fi],true);
15953 for(var i=0;i<nb.length;i++)cd.setUint8(46+i,nb[i]);
15954 parts.push(new Uint8Array(cd.buffer));total+=46+nb.length;
15955 }});
15956 var cdSz=total-cdStart;
15957 var eocd=new DataView(new ArrayBuffer(22));
15958 eocd.setUint32(0,0x06054B50,true);eocd.setUint16(4,0,true);eocd.setUint16(6,0,true);
15959 eocd.setUint16(8,files.length,true);eocd.setUint16(10,files.length,true);
15960 eocd.setUint32(12,cdSz,true);eocd.setUint32(16,cdStart,true);eocd.setUint16(20,0,true);
15961 parts.push(new Uint8Array(eocd.buffer));
15962 var sz=parts.reduce(function(a,p){{return a+p.length;}},0);
15963 var out=new Uint8Array(sz);var off=0;
15964 parts.forEach(function(p){{out.set(p,off);off+=p.length;}});
15965 return out.buffer;
15966 }}
15967
15968 function trendTitleParts(){{
15969 var ySel=document.getElementById('y-sel'),xSel=document.getElementById('x-sel');
15970 var subSelEl=document.getElementById('sub-sel');
15971 var metricLbl=ySel?ySel.options[ySel.selectedIndex].text:'Metric';
15972 var xLbl=xSel?xSel.options[xSel.selectedIndex].text:'';
15973 var proj=(document.getElementById('root-sel').value)||'All projects';
15974 var subTxt=(subSelEl&&subSelEl.value)?(' / '+subSelEl.value):'';
15975 var cnt=(allData&&allData.length)||0;
15976 var now=new Date();
15977 function p2(n){{return(n<10?'0':'')+n;}}
15978 var dstr=now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate())+' '+p2(now.getHours())+':'+p2(now.getMinutes());
15979 return{{title:metricLbl+' \u2014 '+xLbl,sub:'Project: '+proj+subTxt+' \u00b7 '+cnt+' scan'+(cnt===1?'':'s')+' \u00b7 Generated '+dstr,date:dstr}};
15980 }}
15981
15982 function exportPNG(){{
15983 var svgEl=document.querySelector('#chart-wrap svg');
15984 if(!svgEl){{alert('No chart to export yet.');return;}}
15985 var svgStr=new XMLSerializer().serializeToString(svgEl);
15986 var vb=svgEl.viewBox.baseVal,scale=2;
15987 var headerH=84,footerH=36;
15988 var lw=(vb.width||900),lh=(vb.height||380);
15989 var w=lw*scale,h=(lh+headerH+footerH)*scale;
15990 var blob=new Blob([svgStr],{{type:'image/svg+xml'}});
15991 var url=URL.createObjectURL(blob);
15992 var img=new Image();
15993 var tp=trendTitleParts();
15994 img.onload=function(){{
15995 var canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
15996 var ctx=canvas.getContext('2d');
15997 var cs=getComputedStyle(document.body);
15998 var bg=cs.getPropertyValue('--bg').trim()||'#f5efe8';
15999 var oxide=cs.getPropertyValue('--oxide').trim()||'#C45C10';
16000 var muted=cs.getPropertyValue('--muted').trim()||'#7b675b';
16001 ctx.fillStyle=bg;ctx.fillRect(0,0,w,h);
16002 ctx.scale(scale,scale);
16003 ctx.textBaseline='alphabetic';ctx.textAlign='left';
16004 ctx.fillStyle=oxide;ctx.font='800 23px '+FONT;ctx.fillText(tp.title,24,40);
16005 ctx.fillStyle=muted;ctx.font='600 13px '+FONT;ctx.fillText(tp.sub,24,62);
16006 ctx.fillStyle=muted;ctx.font='700 12px '+FONT;ctx.textAlign='right';ctx.fillText('OxideSLOC Trend Report',lw-24,40);ctx.textAlign='left';
16007 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;
16008 ctx.drawImage(img,0,headerH);
16009 var fy=headerH+lh;
16010 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;
16011 ctx.fillStyle=muted;ctx.font='600 11px '+FONT;ctx.textAlign='center';
16012 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);
16013 ctx.textAlign='left';
16014 URL.revokeObjectURL(url);
16015 var a=document.createElement('a');a.download='oxide-sloc-trend.png';a.href=canvas.toDataURL('image/png');a.click();
16016 }};
16017 img.src=url;
16018 }}
16019
16020 function exportPDF(){{
16021 var svgEl=document.querySelector('#chart-wrap svg');
16022 if(!svgEl){{alert('No chart to export yet.');return;}}
16023 var tp=trendTitleParts();
16024 var svgStr=new XMLSerializer().serializeToString(svgEl);
16025 var statsEl=document.getElementById('trend-stats');
16026 var statsHtml=statsEl?statsEl.innerHTML:'';
16027 var yK=document.getElementById('y-sel').value;
16028 var yLabels={{code_lines:'Code Lines',comment_lines:'Comment Lines',blank_lines:'Blank Lines',physical_lines:'Physical Lines',files_analyzed:'Files Analyzed'}};
16029 var yL=yLabels[yK]||yK;
16030 var rowsDesc=allData.slice().sort(function(a,b){{return b.timestamp.localeCompare(a.timestamp);}});
16031 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 class="sx-5f326564" >'+esc(yL)+'</th></tr></thead><tbody>';
16032 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 class="sx-5f326564" >'+fmtFull(Number(d[yK])||0)+'</td></tr>';}});
16033 tableHtml+='</tbody></table>';
16034 var css='<style>'
16035 +'*{{box-sizing:border-box;}}'
16036 +'html,body{{margin:0;padding:0;}}'
16037 // Masthead/footer flow in document order — a position:fixed header repeats
16038 // on every printed page in Chromium and hides the rows beneath it on pages
16039 // 2+. The trend table's <thead> repeats per page natively instead.
16040 +'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;}}'
16041 +'.rep-masthead{{background:#191c26;color:#fff;display:flex;justify-content:space-between;align-items:center;padding:15px 34px;}}'
16042 +'.rep-mast-left{{display:flex;align-items:baseline;gap:14px;}}'
16043 +'.rep-mast-brand{{font-size:19px;font-weight:900;letter-spacing:-.01em;}}'
16044 +'.rep-mast-sub{{font-size:12.5px;color:rgba(255,255,255,0.65);font-weight:600;}}'
16045 +'.rep-mast-ts{{font-size:11px;color:rgba(255,255,255,0.65);font-weight:600;}}'
16046 +'.rep-body{{padding:22px 34px 0;}}'
16047 +'.rep-head{{display:flex;justify-content:space-between;align-items:flex-start;border-bottom:3px solid #C45C10;padding-bottom:14px;margin-bottom:18px;}}'
16048 +'.rep-title{{font-size:23px;font-weight:900;margin:0;color:#241813;}}'
16049 +'.rep-sub{{font-size:13px;color:#7b675b;margin:6px 0 0;}}'
16050 +'.rep-brand{{font-size:14px;font-weight:800;color:#C45C10;text-align:right;white-space:nowrap;}}'
16051 +'.rep-brand small{{display:block;font-weight:600;color:#7b675b;font-size:11px;margin-top:2px;}}'
16052 +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 22px;}}'
16053 +'.stat-chip{{border:1px solid #e6d0bf;border-radius:11px;padding:9px 12px;position:relative;background:#fcf8f3;overflow:hidden;}}'
16054 +'.stat-chip-tip{{display:none!important;}}'
16055 +'.stat-chip-val{{font-size:16px;font-weight:900;color:#C45C10;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}'
16056 +'.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;}}'
16057 +'.stat-chip-exact{{position:absolute;bottom:5px;right:9px;font-size:9px;color:#7b675b;}}'
16058 +'.stat-delta-up{{color:#2a6846;}}.stat-delta-down{{color:#b23030;}}'
16059 +'.rep-chart{{text-align:center;margin:0 0 22px;}}'
16060 +'.rep-chart svg{{max-width:100%;height:auto;}}'
16061 +'.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;}}'
16062 +'.filter-row{{display:none!important;}}'
16063 +'table{{border-collapse:collapse;width:100%;font-size:11px;}}'
16064 +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;}}'
16065 +'th{{background:#f0e9e0;font-weight:800;}}'
16066 +'.sort-icon,.col-resize-handle{{display:none!important;}}'
16067 +'.pagination,.table-pager,.sh-pager{{display:none!important;}}'
16068 +'.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;}}'
16069 +'.rep-foot-gen{{margin-top:2px;color:rgba(255,255,255,0.55);}}'
16070 +'</style>';
16071 var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Trend Report</title>'+css+'</head><body>'
16072 +'<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>'
16073 +'<div class="rep-body">'
16074 +'<div class="rep-head"><div><h1 class="rep-title">'+tp.title+'</h1><p class="rep-sub">'+tp.sub+'</p></div>'
16075 +'<div class="rep-brand">OxideSLOC<small>Trend Report</small></div></div>'
16076 +'<div class="summary-strip">'+statsHtml+'</div>'
16077 +'<div class="rep-chart">'+svgStr+'</div>'
16078 +tableHtml
16079 +'</div>'
16080 +'<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>'
16081 +'</body></html>';
16082 window.slocExportPdf({{html:doc,filename:'oxide-sloc-trend-report.pdf',button:document.getElementById('export-pdf-btn')}});
16083 }}
16084
16085 ['y-sel','x-sel','scale-sel'].forEach(function(id){{
16086 var el=document.getElementById(id);
16087 if(el)el.addEventListener('change',function(){{render(allData);updateStats(allData);}});
16088 }});
16089 // Reflow the width-filling SVG chart when the window resizes (debounced), so it
16090 // tracks the container like the responsive Chart.js charts do.
16091 var _rsT=null;
16092 window.addEventListener('resize',function(){{
16093 if(_rsT)clearTimeout(_rsT);
16094 _rsT=setTimeout(function(){{ if(allData&&allData.length)render(allData); }},150);
16095 }});
16096 rootSel.addEventListener('change',function(){{
16097 populateSubmodules(rootSel.value);
16098 loadAndRender();
16099 }});
16100 if(subSel)subSel.addEventListener('change',loadAndRender);
16101
16102 // ── Full View modal: re-render the trend chart larger using the same drawing code ──
16103 (function(){{
16104 var fvBtn=document.getElementById('tr-chart-fv-btn');
16105 if(!fvBtn)return;
16106 function closeFv(ov){{ if(ov&&ov.parentNode)ov.parentNode.removeChild(ov); hideTT(); }}
16107 fvBtn.addEventListener('click',function(){{
16108 if(!allData||!allData.length){{alert('No chart to expand yet.');return;}}
16109 var yKey=document.getElementById('y-sel').value;
16110 var xMode=document.getElementById('x-sel').value;
16111 var pts=allData;
16112 if(xMode==='tag')pts=allData.filter(function(d){{return d.tags&&d.tags.length>0;}});
16113 pts=pts.slice().sort(function(a,b){{return a.timestamp.localeCompare(b.timestamp);}});
16114 if(!pts.length){{alert('No scan data found for the selected filters.');return;}}
16115 var tp=trendTitleParts();
16116 var ov=document.createElement('div');
16117 ov.className='tr-chart-full-modal';
16118 ov.innerHTML='<div class="tr-chart-full-inner">'
16119 +'<button type="button" class="settings-close sx-6681bcb5" aria-label="Close">'
16120 +'<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>'
16121 +'<div class="sx-d6594fa1" >'+esc(tp.title)+'</div>'
16122 +'<div class="sx-6193e2c8" >'+esc(tp.sub)+'</div>'
16123 +'<div id="tr-fv-chart-wrap" class="chart-wrap"></div></div>';
16124 document.body.appendChild(ov);
16125 var fvWrap=ov.querySelector('#tr-fv-chart-wrap');
16126 renderTrendInto(fvWrap, pts, yKey, xMode, 1.7);
16127 ov.addEventListener('click',function(e){{ if(e.target===ov)closeFv(ov); }});
16128 ov.querySelector('.settings-close').addEventListener('click',function(){{closeFv(ov);}});
16129 document.addEventListener('keydown',function esc2(e){{ if(e.key==='Escape'){{closeFv(ov);document.removeEventListener('keydown',esc2);}} }});
16130 }});
16131 }})();
16132
16133 var xlsxBtn=document.getElementById('export-xlsx-btn');
16134 if(xlsxBtn)xlsxBtn.addEventListener('click',exportXLSX);
16135 var pngBtn=document.getElementById('export-png-btn');
16136 if(pngBtn)pngBtn.addEventListener('click',exportPNG);
16137 var pdfBtn=document.getElementById('export-pdf-btn');
16138 if(pdfBtn)pdfBtn.addEventListener('click',exportPDF);
16139
16140 // ── Clean-up modal ───────────────────────────────────────────────────────
16141 (function(){{
16142 var triggerBtn=document.getElementById('cleanup-runs-btn');
16143 if(!triggerBtn)return;
16144 var modal=document.createElement('div');
16145 modal.className='tr-modal-backdrop';
16146 modal.innerHTML='<div class="tr-modal sx-82c06388" >'
16147 +'<div class="tr-modal-head">'
16148 +'<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>'
16149 +'<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>'
16150 +'</div>'
16151 +'<div class="tr-modal-body">'
16152 +'<p class="sx-06c7d19c" >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>'
16153 +'<label class="sx-c1c2b975" >Delete runs older than</label>'
16154 +'<div class="sx-2412f486" >'
16155 +'<input class="sx-46963105" type="number" id="cleanup-days-input" value="30" min="1" max="3650" >'
16156 +'<span class="sx-68694475" >days</span></div>'
16157 +'<div class="sx-aa34377b" id="cleanup-status" ></div>'
16158 +'</div>'
16159 +'<div class="tr-modal-foot">'
16160 +'<button class="tr-btn tr-btn-secondary" id="cleanup-cancel-btn" type="button">Cancel</button>'
16161 +'<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>'
16162 +'</div></div>';
16163 document.body.appendChild(modal);
16164 triggerBtn.addEventListener('click',function(){{
16165 document.getElementById('cleanup-status').style.display='none';
16166 modal.style.display='flex';
16167 }});
16168 document.getElementById('cleanup-cancel-btn').addEventListener('click',function(){{modal.style.display='none';}});
16169 modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
16170 document.getElementById('cleanup-confirm-btn').addEventListener('click',function(){{
16171 var days=parseInt(document.getElementById('cleanup-days-input').value,10)||30;
16172 var confirmBtn=this;
16173 confirmBtn.disabled=true;
16174 var status=document.getElementById('cleanup-status');
16175 status.style.display='block';
16176 status.style.background='#dbeafe';status.style.color='#1e40af';
16177 status.textContent='Deleting\u2026';
16178 fetch('/api/runs/cleanup',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{older_than_days:days}})}})
16179 .then(function(resp){{
16180 return resp.json().then(function(d){{
16181 if(resp.ok){{
16182 status.style.background='#dcfce7';status.style.color='#166534';
16183 status.textContent='Deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+' older than '+days+' days. Refreshing\u2026';
16184 setTimeout(function(){{window.location.reload();}},1500);
16185 }}else{{
16186 status.style.background='#fee2e2';status.style.color='#991b1b';
16187 status.textContent='Error: '+(d.error||'Unexpected error');
16188 confirmBtn.disabled=false;
16189 }}
16190 }});
16191 }})
16192 .catch(function(e){{
16193 status.style.background='#fee2e2';status.style.color='#991b1b';
16194 status.textContent='Network error: '+String(e);
16195 confirmBtn.disabled=false;
16196 }});
16197 }});
16198 }})();
16199
16200 // ── Retention policy panel ────────────────────────────────────────────────
16201 (function(){{
16202 var triggerBtn=document.getElementById('retention-policy-btn');
16203 if(!triggerBtn)return;
16204 var modal=document.createElement('div');
16205 modal.className='tr-modal-backdrop';
16206 modal.style.zIndex='9001';
16207 modal.innerHTML=''
16208 +'<div class="tr-modal sx-6bb239c6" >'
16209 +'<div class="tr-modal-head">'
16210 +'<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>'
16211 +'<div><h2 class="tr-modal-title">Retention Policy</h2><p class="tr-modal-sub">Scheduled automatic cleanup of old scan runs</p></div>'
16212 +'</div>'
16213 +'<div class="tr-modal-body">'
16214 +'<p class="sx-e0c3d11f" >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>'
16215 +'<div class="sx-62b3da63" >'
16216 +'<input class="sx-d08cd3b7" type="checkbox" id="rp-enabled" >'
16217 +'<label class="sx-4c8da600" for="rp-enabled" >Enable auto-cleanup</label>'
16218 +'</div>'
16219 +'<div class="sx-1641a130" >'
16220 +'<div>'
16221 +'<label class="sx-64dbe608" >Max age (days)</label>'
16222 +'<input class="sx-18abc423" type="number" id="rp-max-age" min="1" max="3650" placeholder="No limit" >'
16223 +'<div class="sx-1214055e" >Delete runs older than N days</div>'
16224 +'</div>'
16225 +'<div>'
16226 +'<label class="sx-64dbe608" >Max runs kept</label>'
16227 +'<input class="sx-18abc423" type="number" id="rp-max-count" min="1" max="10000" placeholder="No limit" >'
16228 +'<div class="sx-1214055e" >Keep only the N most recent runs</div>'
16229 +'</div>'
16230 +'<div>'
16231 +'<label class="sx-64dbe608" >Max total size (MB)</label>'
16232 +'<input class="sx-18abc423" type="number" id="rp-max-total" min="1" max="10000000" placeholder="No limit" >'
16233 +'<div class="sx-1214055e" >Delete oldest runs when the artifact tree exceeds this. Capped by the host SLOC_MAX_DISK_MB ceiling.</div>'
16234 +'</div>'
16235 +'</div>'
16236 +'<div class="sx-3c4a3d44" >'
16237 +'<label class="sx-64dbe608" >Check interval</label>'
16238 +'<select class="sx-163c74e0" id="rp-interval" >'
16239 +'<option value="1">Every hour</option>'
16240 +'<option value="6">Every 6 hours</option>'
16241 +'<option value="12">Every 12 hours</option>'
16242 +'<option value="24" selected>Every 24 hours</option>'
16243 +'<option value="48">Every 2 days</option>'
16244 +'<option value="72">Every 3 days</option>'
16245 +'<option value="168">Every week</option>'
16246 +'</select>'
16247 +'</div>'
16248 +'<div class="sx-6e3619f2" id="rp-last-run" >\u2014</div>'
16249 +'<div class="sx-ef999f9b" id="rp-status" ></div>'
16250 +'</div>'
16251 +'<div class="tr-modal-foot">'
16252 +'<button class="tr-btn tr-btn-secondary" id="rp-close-btn" type="button">Close</button>'
16253 +'<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>'
16254 +'<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>'
16255 +'</div>'
16256 +'</div>';
16257 document.body.appendChild(modal);
16258
16259 function rpShowStatus(msg,ok){{
16260 var s=document.getElementById('rp-status');
16261 s.style.display='block';
16262 s.style.background=ok?'#dcfce7':'#fee2e2';
16263 s.style.color=ok?'#166534':'#991b1b';
16264 s.textContent=msg;
16265 }}
16266 function fmtAgo(iso){{
16267 if(!iso)return'Never';
16268 var diff=Math.floor((Date.now()-new Date(iso).getTime())/1000);
16269 if(diff<60)return diff+'s ago';
16270 if(diff<3600)return Math.floor(diff/60)+'m ago';
16271 if(diff<86400)return Math.floor(diff/3600)+'h ago';
16272 return Math.floor(diff/86400)+'d ago';
16273 }}
16274 function loadPolicy(){{
16275 fetch('/api/cleanup-policy')
16276 .then(function(r){{return r.json();}})
16277 .then(function(d){{
16278 var p=d.policy;
16279 document.getElementById('rp-enabled').checked=p?p.enabled:false;
16280 document.getElementById('rp-max-age').value=(p&&p.max_age_days!=null)?p.max_age_days:'';
16281 document.getElementById('rp-max-count').value=(p&&p.max_run_count!=null)?p.max_run_count:'';
16282 document.getElementById('rp-max-total').value=(p&&p.max_total_mb!=null)?p.max_total_mb:'';
16283 var sel=document.getElementById('rp-interval');
16284 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;}}}}}}
16285 var lr=document.getElementById('rp-last-run');
16286 if(d.last_run_at){{
16287 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'):'');
16288 }}else{{
16289 lr.textContent='Auto-cleanup has not run yet.';
16290 }}
16291 }})
16292 .catch(function(){{document.getElementById('rp-last-run').textContent='Could not load policy.';}});
16293 }}
16294
16295 triggerBtn.addEventListener('click',function(){{
16296 document.getElementById('rp-status').style.display='none';
16297 loadPolicy();
16298 modal.style.display='flex';
16299 }});
16300 document.getElementById('rp-close-btn').addEventListener('click',function(){{modal.style.display='none';}});
16301 modal.addEventListener('click',function(e){{if(e.target===modal)modal.style.display='none';}});
16302
16303 document.getElementById('rp-save-btn').addEventListener('click',function(){{
16304 var enabled=document.getElementById('rp-enabled').checked;
16305 var ageVal=document.getElementById('rp-max-age').value.trim();
16306 var countVal=document.getElementById('rp-max-count').value.trim();
16307 var totalVal=document.getElementById('rp-max-total').value.trim();
16308 var intervalHours=parseInt(document.getElementById('rp-interval').value,10)||24;
16309 if(enabled&&!ageVal&&!countVal&&!totalVal){{
16310 rpShowStatus('Set at least one rule (max age, max count, or max total size) before enabling.',false);
16311 return;
16312 }}
16313 var body={{enabled:enabled,max_age_days:ageVal?parseInt(ageVal,10):null,max_run_count:countVal?parseInt(countVal,10):null,max_total_mb:totalVal?parseInt(totalVal,10):null,interval_hours:intervalHours}};
16314 var saveBtn=document.getElementById('rp-save-btn');
16315 saveBtn.disabled=true;
16316 fetch('/api/cleanup-policy',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify(body)}})
16317 .then(function(r){{
16318 if(r.status===204||r.ok){{rpShowStatus('Policy saved'+(enabled?'. Background task started.':'.'),true);}}
16319 else{{return r.json().then(function(d){{rpShowStatus('Error: '+(d.error||'Unexpected error'),false);}});}}
16320 }})
16321 .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
16322 .finally(function(){{saveBtn.disabled=false;}});
16323 }});
16324
16325 document.getElementById('rp-run-now-btn').addEventListener('click',function(){{
16326 var btn=this;
16327 var orig=btn.innerHTML;
16328 btn.disabled=true;
16329 btn.textContent='Running\u2026';
16330 fetch('/api/cleanup-policy/run-now',{{method:'POST'}})
16331 .then(function(r){{return r.json();}})
16332 .then(function(d){{
16333 rpShowStatus('Cleanup complete: deleted '+d.deleted+' run'+(d.deleted===1?'':'s')+'.',true);
16334 loadPolicy();
16335 }})
16336 .catch(function(e){{rpShowStatus('Network error: '+String(e),false);}})
16337 .finally(function(){{btn.disabled=false;btn.innerHTML=orig;}});
16338 }});
16339 }})();
16340
16341 populateSubmodules(rootSel.value);
16342 loadAndRender();
16343
16344 (function randomizeWatermarks() {{
16345 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
16346 if (!wms.length) return;
16347 var placed = [];
16348 function tooClose(top, left) {{
16349 for (var i = 0; i < placed.length; i++) {{
16350 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
16351 if (dt < 16 && dl < 12) return true;
16352 }}
16353 return false;
16354 }}
16355 function pick(leftBand) {{
16356 for (var attempt = 0; attempt < 50; attempt++) {{
16357 var top = Math.random() * 88 + 2;
16358 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
16359 if (!tooClose(top, left)) {{ placed.push([top, left]); return [top, left]; }}
16360 }}
16361 var top = Math.random() * 88 + 2;
16362 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
16363 placed.push([top, left]); return [top, left];
16364 }}
16365 var half = Math.floor(wms.length / 2);
16366 wms.forEach(function (img, i) {{
16367 var pos = pick(i < half);
16368 var size = Math.floor(Math.random() * 100 + 120);
16369 var rot = (Math.random() * 360).toFixed(1);
16370 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
16371 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;
16372 }});
16373 }})();
16374 (function spawnCodeParticles() {{
16375 var container = document.getElementById('code-particles');
16376 if (!container) return;
16377 var snippets = ['NUM sloc','fn analyze()','code_lines','0 mixed','blanks: NUM','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','NUM physical','files: NUM','NUM comments','cargo build','Ok(run)','Vec<GEN>','match lang','fn main()','.rs .go .py','sloc_core','render_html','NUM code','HashMap<GEN, GN2>','Option<GEN>','&str','author AUT','last_change','ratio 0.CPX','complexity CPX','tokens: NUM','?; // try','let owner','commits: NUM','#[tokio::test]','assert_eq!','PCT% cover','blame AUT','churn PCT%','HEAD~CPX','dedup PCT%','cyclomatic CPX','impl Iterator','.await','+NUM -NM2','ownership','Span<GEN>','pub struct','trait Lang','unsafe','mixed: NUM','anyhow::Result','fn detect()','PathBuf','+NUM loc','coverage PCT%','physical: NUM','ULOC NUM','dup PCT%','let mut run']; var __GT=['Author','String','Token','FileRecord','Lang','u32','usize','PathBuf','Commit','Line','Span','Metric','Record','u64','char','Utf8','Ratio','Blame']; var __AN=['nima','core','web','cli','git','lang','ci','bot','sloc'];
16378 var count = 44;
16379 for (var i = 0; i < count; i++) {{
16380 (function(idx) {{
16381 var el = document.createElement('span');
16382 el.className = 'code-particle';
16383 el.textContent = snippets[idx % snippets.length].split('NUM').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('NM2').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('GEN').join(__GT[Math.floor(Math.random()*__GT.length)]).split('GN2').join(__GT[Math.floor(Math.random()*__GT.length)]).split('AUT').join(__AN[Math.floor(Math.random()*__AN.length)]).split('CPX').join(''+Math.floor(Math.random()*40+1)).split('PCT').join(''+Math.floor(Math.random()*44+1));
16384 var left = Math.random() * 94 + 2;
16385 var top = Math.random() * 88 + 6;
16386 var dur = (Math.random() * 10 + 9).toFixed(1);
16387 var delay = (Math.random() * 18).toFixed(1);
16388 var rot = (Math.random() * 26 - 13).toFixed(1);
16389 var op = (Math.random() * 0.108 + 0.072).toFixed(3);
16390 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';
16391 container.appendChild(el);
16392 }})(i);
16393 }}
16394 }})();
16395 </script>
16396 <footer class="site-footer">
16397 local code analysis - metrics, history and reports
16398 · <em class="footer-mode sx-e01b0d98" id="footer-mode" >oxide-sloc v{version} — Mode: Local</em>
16399 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
16400 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
16401 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
16402 · <a href="/report-bug" rel="noopener">Report a Bug</a> · <a href="/api-docs" rel="noopener">REST API</a>
16403 </footer>
16404 <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>
16405 {toast_assets}
16406</body>
16407</html>"##,
16408 );
16409
16410 Html(html).into_response()
16411}
16412
16413fn compute_cov_pct_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
16414 use std::collections::HashMap;
16415 if !per_file_records.iter().any(|f| f.coverage.is_some()) {
16416 return vec![];
16417 }
16418 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
16419 for rec in per_file_records {
16420 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
16421 let e = totals.entry(lang.display_name().to_string()).or_default();
16422 e.0 += u64::from(cov.lines_found);
16423 e.1 += u64::from(cov.lines_hit);
16424 }
16425 }
16426 #[allow(clippy::cast_precision_loss)] let mut pairs: Vec<(String, f64)> = totals
16428 .into_iter()
16429 .filter(|(_, (found, _))| *found > 0)
16430 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
16431 .collect();
16432 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
16433 pairs
16434 .iter()
16435 .map(|(lang, pct)| serde_json::json!({"lang": lang, "pct": (pct * 10.0).round() / 10.0}))
16436 .collect()
16437}
16438
16439fn compute_cov_tiers(per_file_records: &[sloc_core::FileRecord]) -> (u64, u64, u64) {
16440 let mut high = 0u64;
16441 let mut mid = 0u64;
16442 let mut low = 0u64;
16443 for rec in per_file_records {
16444 if let Some(cov) = &rec.coverage {
16445 if cov.lines_found == 0 {
16446 continue;
16447 }
16448 let pct = f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0;
16449 if pct >= 80.0 {
16450 high += 1;
16451 } else if pct >= 50.0 {
16452 mid += 1;
16453 } else {
16454 low += 1;
16455 }
16456 }
16457 }
16458 (high, mid, low)
16459}
16460
16461fn compute_file_cov_arr(per_file_records: &[sloc_core::FileRecord]) -> Vec<serde_json::Value> {
16462 let mut arr: Vec<serde_json::Value> = per_file_records
16463 .iter()
16464 .filter_map(|rec| {
16465 rec.coverage.as_ref().map(|cov| {
16466 let line_pct = if cov.lines_found > 0 {
16467 (f64::from(cov.lines_hit) / f64::from(cov.lines_found) * 100.0 * 10.0).round()
16468 / 10.0
16469 } else {
16470 0.0
16471 };
16472 let fn_pct = if cov.functions_found > 0 {
16473 (f64::from(cov.functions_hit) / f64::from(cov.functions_found) * 100.0 * 10.0)
16474 .round()
16475 / 10.0
16476 } else {
16477 -1.0
16478 };
16479 serde_json::json!({
16480 "rel": rec.relative_path,
16481 "lang": rec.language.map_or("?", |l| l.display_name()),
16482 "line_pct": line_pct,
16483 "fn_pct": fn_pct,
16484 "lhit": cov.lines_hit,
16485 "lfound": cov.lines_found,
16486 "fhit": cov.functions_hit,
16487 "ffound": cov.functions_found,
16488 })
16489 })
16490 })
16491 .collect();
16492 arr.sort_by(|a, b| {
16493 let pa = a["line_pct"].as_f64().unwrap_or(0.0);
16494 let pb = b["line_pct"].as_f64().unwrap_or(0.0);
16495 pa.partial_cmp(&pb).unwrap_or(std::cmp::Ordering::Equal)
16496 });
16497 arr
16498}
16499
16500#[allow(clippy::cast_precision_loss)] fn build_test_scope_entry(run: &AnalysisRun) -> serde_json::Value {
16502 let mut langs: Vec<&sloc_core::LanguageSummary> = run
16503 .totals_by_language
16504 .iter()
16505 .filter(|l| l.test_count > 0)
16506 .collect();
16507 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
16508 let lang_tests: Vec<serde_json::Value> = langs
16509 .iter()
16510 .map(|l| {
16511 let d = if l.code_lines > 0 {
16512 l.test_count as f64 / l.code_lines as f64 * 1000.0
16513 } else {
16514 0.0
16515 };
16516 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
16517 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
16518 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
16519 })
16520 .collect();
16521 let cov_arr = compute_cov_pct_arr(&run.per_file_records);
16522 let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
16523 let t = &run.summary_totals;
16524 let total_tests = t.test_count;
16525 let density = if t.code_lines > 0 {
16526 total_tests as f64 / t.code_lines as f64 * 1000.0
16527 } else {
16528 0.0
16529 };
16530 let most_tested = langs.first().map_or_else(
16531 || "\u{2014}".to_string(),
16532 |l| l.language.display_name().to_string(),
16533 );
16534 let test_files: u64 = run
16535 .per_file_records
16536 .iter()
16537 .filter(|f| f.raw_line_categories.test_count > 0)
16538 .count() as u64;
16539 let cov_line = if t.coverage_lines_found > 0 {
16540 format!(
16541 "{:.1}",
16542 t.coverage_lines_hit as f64 / t.coverage_lines_found as f64 * 100.0
16543 )
16544 } else {
16545 "0".to_string()
16546 };
16547 let cov_fn = if t.coverage_functions_found > 0 {
16548 format!(
16549 "{:.1}",
16550 t.coverage_functions_hit as f64 / t.coverage_functions_found as f64 * 100.0
16551 )
16552 } else {
16553 "0".to_string()
16554 };
16555 let cov_branch = if t.coverage_branches_found > 0 {
16556 format!(
16557 "{:.1}",
16558 t.coverage_branches_hit as f64 / t.coverage_branches_found as f64 * 100.0
16559 )
16560 } else {
16561 "0".to_string()
16562 };
16563 let has_cov = !cov_arr.is_empty();
16564 let file_cov_arr = compute_file_cov_arr(&run.per_file_records);
16565 serde_json::json!({
16566 "totals": {
16567 "test_count": total_tests,
16568 "assertions": t.test_assertion_count,
16569 "suites": t.test_suite_count,
16570 "test_files": test_files,
16571 "total_files": t.files_analyzed,
16572 "density_str": format!("{density:.1}"),
16573 "most_tested": most_tested,
16574 "langs_with_tests": langs.len(),
16575 "cov_line": cov_line,
16576 "cov_fn": cov_fn,
16577 "cov_branch": cov_branch,
16578 },
16579 "lang_tests": lang_tests,
16580 "cov": cov_arr,
16581 "cov_tiers": {"high": high, "mid": mid, "low": low},
16582 "file_cov": file_cov_arr,
16583 "has_coverage": has_cov,
16584 "submodules": {},
16585 })
16586}
16587
16588#[allow(clippy::cast_precision_loss)] fn build_test_scope_sub_entry(sub: &sloc_core::SubmoduleSummary) -> serde_json::Value {
16590 let mut langs: Vec<&sloc_core::LanguageSummary> = sub
16591 .language_summaries
16592 .iter()
16593 .filter(|l| l.test_count > 0)
16594 .collect();
16595 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
16596 let lang_tests: Vec<serde_json::Value> = langs
16597 .iter()
16598 .map(|l| {
16599 let d = if l.code_lines > 0 {
16600 l.test_count as f64 / l.code_lines as f64 * 1000.0
16601 } else {
16602 0.0
16603 };
16604 serde_json::json!({"lang": l.language.display_name(), "tests": l.test_count,
16605 "assertions": l.test_assertion_count, "suites": l.test_suite_count,
16606 "code": l.code_lines, "density": (d * 100.0).round() / 100.0, "files": l.files})
16607 })
16608 .collect();
16609 let total_tests: u64 = langs.iter().map(|l| l.test_count).sum();
16610 let total_assertions: u64 = langs.iter().map(|l| l.test_assertion_count).sum();
16611 let total_suites: u64 = langs.iter().map(|l| l.test_suite_count).sum();
16612 let test_files_approx: u64 = langs.iter().map(|l| l.files).sum();
16613 let density = if sub.code_lines > 0 {
16614 total_tests as f64 / sub.code_lines as f64 * 1000.0
16615 } else {
16616 0.0
16617 };
16618 let most_tested = langs.first().map_or_else(
16619 || "\u{2014}".to_string(),
16620 |l| l.language.display_name().to_string(),
16621 );
16622 serde_json::json!({
16623 "totals": {
16624 "test_count": total_tests,
16625 "assertions": total_assertions,
16626 "suites": total_suites,
16627 "test_files": test_files_approx,
16628 "total_files": sub.files_analyzed,
16629 "density_str": format!("{density:.1}"),
16630 "most_tested": most_tested,
16631 "langs_with_tests": langs.len(),
16632 "cov_line": "0",
16633 "cov_fn": "0",
16634 "cov_branch": "0",
16635 },
16636 "lang_tests": lang_tests,
16637 "cov": [],
16638 "cov_tiers": {"high": 0, "mid": 0, "low": 0},
16639 "has_coverage": false,
16640 })
16641}
16642
16643fn compute_cov_json_str(run: &AnalysisRun) -> String {
16644 use std::collections::HashMap;
16645 let mut totals: HashMap<String, (u64, u64)> = HashMap::new();
16646 for rec in &run.per_file_records {
16647 if let (Some(lang), Some(cov)) = (rec.language, &rec.coverage) {
16648 let e = totals.entry(lang.display_name().to_string()).or_default();
16649 e.0 += u64::from(cov.lines_found);
16650 e.1 += u64::from(cov.lines_hit);
16651 }
16652 }
16653 #[allow(clippy::cast_precision_loss)] let mut pairs: Vec<(String, f64)> = totals
16655 .into_iter()
16656 .filter(|(_, (found, _))| *found > 0)
16657 .map(|(lang, (found, hit))| (lang, hit as f64 / found as f64 * 100.0))
16658 .collect();
16659 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
16660 let parts: Vec<String> = pairs
16661 .iter()
16662 .map(|(lang, pct)| {
16663 let name = lang.replace('"', "\\\"");
16664 format!(r#"{{"lang":"{name}","pct":{pct:.1}}}"#)
16665 })
16666 .collect();
16667 format!("[{}]", parts.join(","))
16668}
16669
16670fn compute_cov_tier_json_str(run: &AnalysisRun) -> String {
16671 let (high, mid, low) = compute_cov_tiers(&run.per_file_records);
16672 format!(r#"{{"high":{high},"mid":{mid},"low":{low}}}"#)
16673}
16674
16675fn build_scope_entry_for_run(run: &AnalysisRun) -> serde_json::Value {
16676 let mut entry = build_test_scope_entry(run);
16677 if !run.submodule_summaries.is_empty() {
16678 let subs: serde_json::Map<String, serde_json::Value> = run
16679 .submodule_summaries
16680 .iter()
16681 .map(|sub| (sub.name.clone(), build_test_scope_sub_entry(sub)))
16682 .collect();
16683 entry["submodules"] = serde_json::Value::Object(subs);
16684 }
16685 entry
16686}
16687
16688fn lang_test_entry_json(l: &sloc_core::LanguageSummary) -> String {
16689 let name = l.language.display_name().replace('"', "\\\"");
16690 #[allow(clippy::cast_precision_loss)] let density = if l.code_lines > 0 {
16692 l.test_count as f64 / l.code_lines as f64 * 1000.0
16693 } else {
16694 0.0
16695 };
16696 format!(
16697 r#"{{"lang":"{name}","tests":{t},"assertions":{a},"suites":{s},"code":{c},"density":{d:.2},"files":{f}}}"#,
16698 name = name,
16699 t = l.test_count,
16700 a = l.test_assertion_count,
16701 s = l.test_suite_count,
16702 c = l.code_lines,
16703 d = density,
16704 f = l.files,
16705 )
16706}
16707
16708fn build_lang_tests_json(run: Option<&AnalysisRun>) -> String {
16709 let Some(r) = run else {
16710 return "[]".to_string();
16711 };
16712 let mut langs: Vec<&sloc_core::LanguageSummary> = r
16713 .totals_by_language
16714 .iter()
16715 .filter(|l| l.test_count > 0)
16716 .collect();
16717 langs.sort_by_key(|l| std::cmp::Reverse(l.test_count));
16718 let parts: Vec<String> = langs.iter().map(|l| lang_test_entry_json(l)).collect();
16719 format!("[{}]", parts.join(","))
16720}
16721
16722async fn build_scope_data_json(state: &AppState, latest_run: Option<&AnalysisRun>) -> String {
16724 let mut scope_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
16725 scope_map.insert(
16726 "__all__".to_string(),
16727 latest_run.map_or_else(
16728 || {
16729 serde_json::json!({"totals":{"test_count":0,"assertions":0,"suites":0,
16730 "test_files":0,"total_files":0,"density_str":"0.0","most_tested":"\u{2014}",
16731 "langs_with_tests":0,"cov_line":"0","cov_fn":"0","cov_branch":"0"},
16732 "lang_tests":[],"cov":[],"cov_tiers":{"high":0,"mid":0,"low":0},
16733 "has_coverage":false,"submodules":{}})
16734 },
16735 build_test_scope_entry,
16736 ),
16737 );
16738 let all_roots: Vec<String> = {
16739 let reg = state.registry.lock().await;
16740 let mut seen = std::collections::BTreeSet::new();
16741 reg.entries
16742 .iter()
16743 .flat_map(|e| e.input_roots.iter().cloned())
16744 .filter(|r| seen.insert(r.clone()))
16745 .collect()
16746 };
16747 for root in &all_roots {
16748 let json_path = {
16749 let reg = state.registry.lock().await;
16750 reg.entries
16751 .iter()
16752 .find(|e| e.input_roots.iter().any(|r| r == root))
16753 .and_then(|e| e.json_path.clone())
16754 };
16755 let run_for_root: Option<AnalysisRun> = if let Some(p) = json_path {
16756 let json_str = tokio::fs::read_to_string(&p).await.ok();
16757 json_str
16758 .as_deref()
16759 .and_then(|s| serde_json::from_str(s).ok())
16760 } else {
16761 None
16762 };
16763 if let Some(ref run) = run_for_root {
16764 scope_map.insert(root.clone(), build_scope_entry_for_run(run));
16765 }
16766 }
16767 serde_json::to_string(&scope_map).unwrap_or_else(|_| "{}".to_string())
16768}
16769
16770#[allow(clippy::cast_precision_loss)] #[allow(clippy::too_many_lines)] async fn test_metrics_handler(
16774 State(state): State<AppState>,
16775 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
16776) -> Response {
16777 auto_scan_watched_dirs(&state).await;
16778 let watched_dirs_list: Vec<String> = {
16779 let wd = state.watched_dirs.lock().await;
16780 wd.dirs.iter().map(|p| p.display().to_string()).collect()
16781 };
16782 let latest_run: Option<AnalysisRun> = {
16783 let json_path = {
16784 let reg = state.registry.lock().await;
16785 reg.entries.first().and_then(|e| e.json_path.clone())
16786 };
16787 if let Some(p) = json_path {
16788 let json_str = tokio::fs::read_to_string(&p).await.ok();
16789 json_str
16790 .as_deref()
16791 .and_then(|s| serde_json::from_str(s).ok())
16792 } else {
16793 None
16794 }
16795 };
16796
16797 let _lang_tests_json = build_lang_tests_json(latest_run.as_ref());
16799
16800 let cov_json: String = latest_run
16802 .as_ref()
16803 .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
16804 .map_or_else(|| "[]".to_string(), compute_cov_json_str);
16805
16806 let _cov_tier_json: String = latest_run
16808 .as_ref()
16809 .filter(|r| r.per_file_records.iter().any(|f| f.coverage.is_some()))
16810 .map_or_else(
16811 || r#"{"high":0,"mid":0,"low":0}"#.to_string(),
16812 compute_cov_tier_json_str,
16813 );
16814
16815 let total_tests: u64 = latest_run
16816 .as_ref()
16817 .map_or(0, |r| r.summary_totals.test_count);
16818 let total_assertions: u64 = latest_run
16819 .as_ref()
16820 .map_or(0, |r| r.summary_totals.test_assertion_count);
16821 let total_suites: u64 = latest_run
16822 .as_ref()
16823 .map_or(0, |r| r.summary_totals.test_suite_count);
16824 let total_code: u64 = latest_run
16825 .as_ref()
16826 .map_or(0, |r| r.summary_totals.code_lines);
16827 let workspace_density: f64 = if total_code > 0 {
16828 total_tests as f64 / total_code as f64 * 1000.0
16829 } else {
16830 0.0
16831 };
16832 let langs_with_tests: usize = latest_run.as_ref().map_or(0, |r| {
16833 r.totals_by_language
16834 .iter()
16835 .filter(|l| l.test_count > 0)
16836 .count()
16837 });
16838 let most_tested: String = latest_run
16839 .as_ref()
16840 .and_then(|r| {
16841 r.totals_by_language
16842 .iter()
16843 .filter(|l| l.test_count > 0)
16844 .max_by_key(|l| l.test_count)
16845 })
16846 .map_or_else(
16847 || "\u{2014}".to_string(),
16848 |l| l.language.display_name().to_string(),
16849 );
16850 let test_files_count: u64 = latest_run.as_ref().map_or(0, |r| {
16851 r.per_file_records
16852 .iter()
16853 .filter(|f| f.raw_line_categories.test_count > 0)
16854 .count() as u64
16855 });
16856 let total_files_analyzed: u64 = latest_run
16857 .as_ref()
16858 .map_or(0, |r| r.summary_totals.files_analyzed);
16859 let has_coverage = !cov_json.starts_with("[]") && cov_json.len() > 2;
16860
16861 let cov_line_pct_str: String = latest_run
16863 .as_ref()
16864 .filter(|r| r.summary_totals.coverage_lines_found > 0)
16865 .map_or_else(
16866 || "0".to_string(),
16867 |r| {
16868 format!(
16869 "{:.1}",
16870 r.summary_totals.coverage_lines_hit as f64
16871 / r.summary_totals.coverage_lines_found as f64
16872 * 100.0
16873 )
16874 },
16875 );
16876 let cov_fn_pct_str: String = latest_run
16877 .as_ref()
16878 .filter(|r| r.summary_totals.coverage_functions_found > 0)
16879 .map_or_else(
16880 || "0".to_string(),
16881 |r| {
16882 format!(
16883 "{:.1}",
16884 r.summary_totals.coverage_functions_hit as f64
16885 / r.summary_totals.coverage_functions_found as f64
16886 * 100.0
16887 )
16888 },
16889 );
16890 let cov_branch_pct_str: String = latest_run
16891 .as_ref()
16892 .filter(|r| r.summary_totals.coverage_branches_found > 0)
16893 .map_or_else(
16894 || "0".to_string(),
16895 |r| {
16896 format!(
16897 "{:.1}",
16898 r.summary_totals.coverage_branches_hit as f64
16899 / r.summary_totals.coverage_branches_found as f64
16900 * 100.0
16901 )
16902 },
16903 );
16904
16905 let cov_no_data_notice = if has_coverage {
16906 String::new()
16907 } else {
16908 String::from(
16909 r#"<div class="empty-state sx-c2289694" >
16910<div class="sx-1355ce7d" >No code coverage data found for the latest scan. Re-run with a coverage file to enable line, function, and branch coverage metrics.</div>
16911<div class="sx-823a8ee4" >
16912 <span class="sx-4041551f" >Supported formats</span>
16913 <span class="sx-4c7d880c" ><strong>LCOV</strong> <code>.info</code></span>
16914 <span class="sx-3dc23ded" >·</span>
16915 <span class="sx-4c7d880c" ><strong>Cobertura XML</strong></span>
16916 <span class="sx-3dc23ded" >·</span>
16917 <span class="sx-4c7d880c" ><strong>JaCoCo XML</strong></span>
16918 <span class="sx-3dc23ded" >·</span>
16919 <span class="sx-4c7d880c" ><strong>coverage.py JSON</strong></span>
16920 <span class="sx-3dc23ded" >·</span>
16921 <span class="sx-4c7d880c" ><strong>Istanbul JSON</strong></span>
16922</div>
16923<div class="sx-9d20ccc1" >Provide the file via the web scan form or <code>--coverage-file</code> CLI flag.</div>
16924</div>"#,
16925 )
16926 };
16927
16928 let workspace_density_str = format!("{workspace_density:.1}");
16929 let nonce = &csp_nonce;
16930 let toast_assets = sloc_toast_assets(nonce);
16931 let version = env!("CARGO_PKG_VERSION");
16932
16933 let watched_dirs_html: String = if state.server_mode {
16936 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()
16937 } else {
16938 let watched_dirs_chips: String = if watched_dirs_list.is_empty() {
16939 r#"<span class="watched-none">No folders watched — click Choose to add one</span>"#
16940 .to_string()
16941 } else {
16942 watched_dirs_list
16943 .iter()
16944 .fold(String::new(), |mut s, d| {
16945 use std::fmt::Write as _;
16946 let escaped =
16947 d.replace('&', "&").replace('"', """).replace('<', "<");
16948 write!(
16949 s,
16950 r#"<span class="watched-chip"><span class="watched-chip-path" title="{escaped}">{escaped}</span><form class="sx-043808a9" method="POST" action="/watched-dirs/remove" ><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>"#
16951 ).expect("write to String is infallible");
16952 s
16953 })
16954 };
16955 format!(
16956 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 class="sx-043808a9" method="POST" action="/watched-dirs/refresh" ><input type="hidden" name="redirect_to" value="/test-metrics"><button type="submit" class="btn">↻ Refresh</button></form></div></div>"#
16957 )
16958 };
16959
16960 let scope_data_json = build_scope_data_json(&state, latest_run.as_ref()).await;
16962
16963 let html = format!(
16964 r#"<!doctype html>
16965<html lang="en">
16966<head>
16967 <meta charset="utf-8" />
16968 <meta name="viewport" content="width=device-width, initial-scale=1" />
16969 <title>OxideSLOC | Test Metrics</title>
16970 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
16971 <link rel="stylesheet" href="/static/app.css">
16972 <script src="/static/app.js"></script>
16973 <style nonce="{nonce}">
16974 :root {{
16975 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
16976 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
16977 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
16978 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
16979 --info-bg:#eef3ff; --info-text:#4467d8;
16980 }}
16981 body.dark-theme {{ --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }}
16982 *{{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;}}
16983 .background-watermarks{{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}}
16984 .background-watermarks img{{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}}
16985 .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:13px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}}
16986 @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));}}}}
16987 .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);}}
16988 .top-nav-inner{{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}}
16989 .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));}}
16990 .brand-copy{{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}}
16991 .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;}}
16992 .nav-right{{margin-left:auto;display:flex;align-items:center;gap:10px;}}
16993 @media (max-width:1400px) {{ .nav-right {{ gap:6px; }} .nav-pill,.nav-dropdown-btn,.theme-toggle {{ padding:0 10px; }} }}
16994 @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; }} }}
16995 .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;}}
16996 .nav-pill:hover{{background:rgba(255,255,255,0.18);transform:translateY(-1px);}}
16997 .theme-toggle{{width:38px;justify-content:center;padding:0;cursor:pointer;}} .theme-toggle:hover{{transform:translateY(-1px);background:rgba(255,255,255,0.16);}}
16998 .theme-toggle svg{{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}}
16999 .theme-toggle .icon-sun{{display:none;}} body.dark-theme .theme-toggle .icon-sun{{display:block;}} body.dark-theme .theme-toggle .icon-moon{{display:none;}}
17000 .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;}}
17001 .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;}}
17002 .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;}}
17003 .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;}}
17004 .settings-modal.open{{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}}
17005 .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);}}
17006 .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;}}
17007 .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;}}
17008 .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;}}
17009 .scheme-grid{{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}}
17010 .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;}}
17011 .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);}}
17012 .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;}}
17013 .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;}}
17014 .tz-select:focus{{border-color:var(--oxide);}}
17015 .page{{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}}
17016 @media (max-width:1920px) {{ .top-nav-inner {{ max-width:1500px; }} .page {{ max-width:1500px; }} }}
17017 .panel{{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px;margin-bottom:18px;}}
17018 h1{{margin:0 0 4px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}}
17019 .muted{{color:var(--muted);font-size:13px;line-height:1.6;margin:0 0 16px;}}
17020 .summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}}
17021 @media(max-width:800px){{.summary-strip{{grid-template-columns:repeat(2,1fr);}}}}
17022 .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);}}
17023 .stat-chip:hover{{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}}
17024 .stat-chip-val{{font-size:20px;font-weight:900;color:var(--oxide);}}
17025 .stat-chip-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}}
17026 .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;}}
17027 .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;}}
17028 .stat-chip-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
17029 .stat-chip:hover .stat-chip-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
17030 .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);}}
17031 .section-header:first-child{{margin-top:0;padding-top:0;border-top:none;}}
17032 .chart-row{{display:grid;gap:18px;grid-template-columns:1fr 1fr;margin-bottom:18px;}}
17033 @media(max-width:900px){{.chart-row{{grid-template-columns:1fr;}}}}
17034 .chart-box{{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px;}}
17035 .chart-box-title{{font-size:12px;font-weight:800;color:var(--muted-2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:12px;}}
17036 .chart-canvas-wrap{{position:relative;height:280px;}}
17037 .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;}}
17038 .chart-no-data svg{{opacity:0.35;}}
17039 .chart-no-data-title{{font-weight:700;font-size:13px;color:var(--muted-2);}}
17040 .chart-no-data-hint{{font-size:11px;color:var(--muted);text-align:center;max-width:220px;line-height:1.5;}}
17041 .data-table{{width:100%;border-collapse:collapse;font-size:13px;}}
17042 .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;}}
17043 .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;}}
17044 .data-table tr:last-child td{{border-bottom:none;}}
17045 .data-table tbody tr:hover td{{background:var(--surface-2);}}
17046 .num{{text-align:right!important;font-variant-numeric:tabular-nums;}}
17047 .density-bar-wrap{{display:flex;align-items:center;gap:8px;}}
17048 .density-bar{{height:6px;border-radius:3px;background:var(--oxide);opacity:0.75;min-width:2px;flex-shrink:0;}}
17049 .cov-gauge-row{{display:grid!important;grid-template-columns:repeat(3,1fr)!important;gap:16px;margin-bottom:18px;}}
17050 .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;}}
17051 .cov-gauge-card:hover{{transform:translateY(-3px);box-shadow:0 10px 28px rgba(77,44,20,0.15);}}
17052 .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);}}
17053 .cov-gauge-tip::after{{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}}
17054 .cov-gauge-card:hover .cov-gauge-tip{{opacity:1;transform:translateX(-50%) translateY(0);}}
17055 .cov-gauge-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);}}
17056 .cov-gauge-val{{font-size:32px;font-weight:900;line-height:1;}}
17057 .cov-gauge-track{{height:8px;border-radius:4px;background:var(--line);overflow:hidden;}}
17058 .cov-gauge-fill{{height:100%;border-radius:4px;transition:width .5s ease;}}
17059 .cov-gauge-sub{{font-size:11px;color:var(--muted);}}
17060 @media(max-width:700px){{.cov-gauge-row{{grid-template-columns:1fr!important;}}}}
17061 .controls-row{{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:16px;}}
17062 .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;}}
17063 .chart-select:focus{{border-color:var(--accent);}}
17064 .empty-state{{padding:32px;text-align:center;color:var(--muted);font-size:14px;border:1px dashed var(--line-strong);border-radius:12px;}}
17065 .trend-canvas-wrap{{position:relative;height:260px;}}
17066 .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;}}
17067 .trend-controls-bar label{{font-size:13px;font-weight:700;color:var(--muted);display:flex;align-items:center;gap:7px;}}
17068 .site-footer{{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}}
17069 .site-footer a{{color:var(--muted);}}
17070 body.dark-theme .chart-box{{border-color:var(--line-strong);}}
17071 .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;}}
17072 .btn:hover{{background:var(--surface-2);}}
17073 .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;}}
17074 .export-btn:hover{{background:var(--line);}}
17075 .export-btn svg{{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2.2;}}
17076 /* Page-level export controls (Scope toolbar, right-aligned) — identical style to View Reports */
17077 .export-group{{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}}
17078 .scope-export{{margin-left:auto;}}
17079 body.pdf-mode .export-group{{display:none!important;}}
17080 @media (max-width:720px){{.scope-export{{margin-left:0;width:100%;}}}}
17081 .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;}}
17082 .scope-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
17083 .scope-sel-wrap{{display:flex;align-items:center;gap:10px;flex:1;flex-wrap:wrap;}}
17084 .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;}}
17085 .scope-sel:focus{{border-color:var(--accent);}}
17086 body.dark-theme .scope-sel{{background:var(--surface);color:var(--text);}}
17087 .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;}}
17088 .watched-bar-left{{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}}
17089 .watched-label{{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}}
17090 .watched-chips{{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}}
17091 .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;}}
17092 .watched-chip-path{{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}}
17093 .watched-chip-rm{{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}}
17094 .watched-chip-rm:hover{{color:var(--oxide);}}
17095 .watched-none{{font-size:11px;color:var(--muted);font-style:italic;}}
17096 .watched-bar-right{{display:flex;gap:6px;align-items:center;flex-shrink:0;}}
17097 .watched-bar-right .btn{{box-sizing:border-box;height:28px;}}
17098 body.dark-theme .watched-chip{{background:rgba(255,255,255,0.05);}}
17099 .cov-file-toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;}}
17100 .cov-filter-tabs{{display:flex;gap:6px;flex-wrap:wrap;}}
17101 .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;}}
17102 .cov-tab.active,.cov-tab:hover{{background:var(--oxide);border-color:var(--oxide-2);color:#fff;}}
17103 .cov-tab[data-tier="high"].active{{background:#2a6846;border-color:#1f5035;}}
17104 .cov-tab[data-tier="mid"].active{{background:#b58a00;border-color:#9a7400;}}
17105 .cov-tab[data-tier="low"].active,.cov-tab[data-tier="zero"].active{{background:#b23030;border-color:#8f2626;}}
17106 .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;}}
17107 .cov-file-search:focus{{border-color:var(--accent);}}
17108 .cov-pct-badge{{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums;}}
17109 .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;}}
17110 body.dark-theme .cov-file-search{{background:var(--surface);}}
17111 .chart-box-header{{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}}
17112 .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;}}
17113 .chart-expand-btn:hover{{background:var(--surface-2);color:var(--text);}}
17114 .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;}}
17115 .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);}}
17116 .chart-modal-title{{font-size:15px;font-weight:800;text-transform:uppercase;letter-spacing:.05em;color:var(--text);margin:0 0 2px;display:block;}}
17117 .chart-modal-subtitle{{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 16px;display:block;letter-spacing:.02em;}}
17118 .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;}}
17119 .chart-modal-close:hover{{opacity:.7;}}
17120 body.dark-theme .chart-modal{{background:var(--surface);}}
17121 </style>
17122</head>
17123<body>
17124 <div class="background-watermarks" aria-hidden="true">
17125 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17126 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17127 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17128 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17129 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17130 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
17131 </div>
17132 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
17133 <div class="top-nav">
17134 <div class="top-nav-inner">
17135 <a class="brand" href="/">
17136 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
17137 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Test metrics</div></div>
17138 </a>
17139 <div class="nav-right">
17140 <a class="nav-pill" href="/">Home</a>
17141 <div class="nav-dropdown">
17142 <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>
17143 <div class="nav-dropdown-menu">
17144 <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>
17145 </div>
17146 </div>
17147 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
17148 <a class="nav-pill sx-8c38ef73" href="/test-metrics" >Test Metrics</a>
17149 <div class="nav-dropdown">
17150 <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>
17151 <div class="nav-dropdown-menu">
17152 <a href="/code-ownership"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>Code Ownership</a>
17153 <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>
17154 </div>
17155 </div>
17156 <div class="server-status-wrap" id="server-status-wrap">
17157 <div class="nav-pill server-online-pill" id="server-status-pill">
17158 <span class="status-dot" id="status-dot"></span>
17159 <span id="server-status-label">Server</span>
17160 <span class="sx-d60f2ef3" id="server-ping-ms" ></span>
17161 </div>
17162 <div class="server-status-tip">
17163 OxideSLOC is running — accessible on your network.
17164 <span class="sx-238af6bc" id="server-tip-ping" ></span>
17165 </div>
17166 </div>
17167 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
17168 <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>
17169 </button>
17170 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
17171 <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>
17172 <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>
17173 </button>
17174 </div>
17175 </div>
17176 </div>
17177
17178 <div class="page">
17179 {watched_dirs_html}
17180 <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
17181 <div class="scan-overlay-card">
17182 <div class="scan-spinner"></div>
17183 <div class="scan-overlay-text">Scanning folder…</div>
17184 <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
17185 </div>
17186 </div>
17187 <style nonce="{nonce}">
17188 .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);}}
17189 .scan-overlay.active{{display:flex;}}
17190 .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;}}
17191 .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;}}
17192 @keyframes scanSpin{{to{{transform:rotate(360deg);}}}}
17193 .scan-overlay-text{{font-size:15px;font-weight:800;color:var(--text);}}
17194 .scan-overlay-sub{{font-size:12px;color:var(--muted);line-height:1.5;}}
17195 </style>
17196 <div class="scope-bar">
17197 <svg class="sx-e1242e80" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
17198 <span class="scope-label">Scope</span>
17199 <div class="scope-sel-wrap">
17200 <select id="scope-root-sel" class="scope-sel"><option value="__all__">All projects</option></select>
17201 <div class="sx-8c9231df" id="scope-sub-wrap" >
17202 <svg class="sx-88d1e2d6" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ><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>
17203 <select id="scope-sub-sel" class="scope-sel"><option value="">Entire project</option></select>
17204 </div>
17205 </div>
17206 <!-- Page-level export: covers the whole page (Test Metrics + LCOV Coverage Summary) for the selected scope. -->
17207 <div class="export-group scope-export" id="tm-export-group">
17208 <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)">
17209 <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>
17210 Export Excel
17211 </button>
17212 <button type="button" class="export-btn" id="tm-export-png-btn" title="Save the whole page's charts as a PNG image">
17213 <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>
17214 Export PNG
17215 </button>
17216 <button type="button" class="export-btn" id="tm-export-pdf-btn" title="Export the whole page as a printable PDF report">
17217 <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>
17218 Export PDF
17219 </button>
17220 </div>
17221 </div>
17222 <div class="summary-strip sx-17674eb3" >
17223 <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>
17224 <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>
17225 <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>
17226 <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>
17227 </div>
17228 <div class="summary-strip sx-17674eb3" >
17229 <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>
17230 <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>
17231 <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>
17232 <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>
17233 </div>
17234
17235 <div class="panel" id="viz-panel">
17236 <div class="section-header sx-ea7dba9f" >Visualizations</div>
17237
17238 <div class="chart-box sx-50b6af6d" >
17239 <div class="chart-box-header">
17240 <div class="chart-box-title sx-768bda7d" >Test Count Trend</div>
17241 <div class="sx-0d5ff492" >
17242 <button class="chart-expand-btn sx-d0466aa3" id="multi-compare-trend-btn" title="Open all scans in Multi-Scan Timeline" >⇌ Multi-Timeline</button>
17243 <button class="chart-expand-btn" id="trend-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
17244 </div>
17245 </div>
17246 <p class="sx-33c9de91" >Test metric trends across all saved scans for the selected scope. Use <strong>Multi-Timeline</strong> to compare scans side-by-side.</p>
17247 <div class="trend-controls-bar">
17248 <label>Y Metric:
17249 <select class="chart-select" id="tm-trend-y">
17250 <option value="test_count" selected>Test Definitions</option>
17251 <option value="code_lines">Code Lines</option>
17252 </select>
17253 </label>
17254 <label>X Axis:
17255 <select class="chart-select" id="tm-trend-x">
17256 <option value="commit" selected>By Commit</option>
17257 <option value="time">By Time</option>
17258 </select>
17259 </label>
17260 <label class="sx-d0466aa3" id="tm-sub-label" >Submodule:
17261 <select class="chart-select" id="tm-trend-sub">
17262 <option value="">All (project total)</option>
17263 </select>
17264 </label>
17265 <label>Chart Size:
17266 <select class="chart-select" id="tm-trend-size">
17267 <option value="200">Compact</option>
17268 <option value="260" selected>Normal</option>
17269 <option value="360">Large</option>
17270 </select>
17271 </label>
17272 </div>
17273 <div class="chart-canvas-wrap trend-canvas-wrap" id="trend-canvas-wrap"><canvas id="canvas-trend"></canvas></div>
17274 <div id="trend-empty" class="empty-state sx-d0466aa3" >No historical test data found. Run more scans to see trends.</div>
17275 </div>
17276
17277 <div class="chart-row">
17278 <div class="chart-box">
17279 <div class="chart-box-header">
17280 <div class="chart-box-title sx-768bda7d" >Test Definitions by Language</div>
17281 <button class="chart-expand-btn" id="tests-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
17282 </div>
17283 <div class="chart-canvas-wrap"><canvas id="canvas-tests"></canvas></div>
17284 <div id="no-data-tests" class="chart-no-data sx-d0466aa3" ><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>
17285 </div>
17286 <div class="chart-box">
17287 <div class="chart-box-header">
17288 <div class="chart-box-title sx-768bda7d" >Test Density (per 1,000 code lines)</div>
17289 <button class="chart-expand-btn" id="density-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
17290 </div>
17291 <div class="chart-canvas-wrap"><canvas id="canvas-density"></canvas></div>
17292 <div id="no-data-density" class="chart-no-data sx-d0466aa3" ><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>
17293 </div>
17294 </div>
17295
17296 <div class="chart-row">
17297 <div class="chart-box">
17298 <div class="chart-box-header">
17299 <div class="chart-box-title sx-768bda7d" >Assertions by Language</div>
17300 <button class="chart-expand-btn" id="assertions-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
17301 </div>
17302 <div class="chart-canvas-wrap"><canvas id="canvas-assertions"></canvas></div>
17303 <div id="no-data-assertions" class="chart-no-data sx-d0466aa3" ><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>
17304 </div>
17305 <div class="chart-box" id="suites-chart-box">
17306 <div class="chart-box-header">
17307 <div class="chart-box-title sx-768bda7d" >Test Suites by Language</div>
17308 <button class="chart-expand-btn" id="suites-expand-btn" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
17309 </div>
17310 <div class="chart-canvas-wrap"><canvas id="canvas-suites"></canvas></div>
17311 <div id="no-data-suites" class="chart-no-data sx-d0466aa3" ><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>
17312 </div>
17313 </div>
17314
17315 <div class="chart-row">
17316 <div class="chart-box">
17317 <div class="chart-box-title">Test Files Breakdown</div>
17318 <div class="chart-canvas-wrap sx-bbc01430" ><canvas id="canvas-files"></canvas></div>
17319 <div id="no-data-files" class="chart-no-data sx-d0466aa3" ><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>
17320 </div>
17321 <div class="chart-box">
17322 <div class="chart-box-title">Test Composition</div>
17323 <p class="sx-7f7492d2" >Total counts: test functions, assertions, and suites workspace-wide.</p>
17324 <div class="chart-canvas-wrap"><canvas id="canvas-composition"></canvas></div>
17325 <div id="no-data-composition" class="chart-no-data sx-d0466aa3" ><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>
17326 </div>
17327 </div>
17328 </div>
17329
17330 <div class="panel">
17331 <h1>Test Metrics</h1>
17332 <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>
17333
17334 <div class="section-header">Language Breakdown</div>
17335 {cov_no_data_notice}
17336 <div class="sx-fbff25e9" >
17337 <table class="data-table" id="lang-table">
17338 <thead><tr>
17339 <th>Language</th>
17340 <th class="num">Test Fns</th>
17341 <th class="num">Assertions</th>
17342 <th class="num">Suites</th>
17343 <th class="num">Code Lines</th>
17344 <th class="num">Files</th>
17345 <th class="num">Density / 1K</th>
17346 <th>Relative Density</th>
17347 </tr></thead>
17348 <tbody id="lang-tbody"></tbody>
17349 </table>
17350 </div>
17351 </div>
17352
17353 <div class="panel sx-d0466aa3" id="cov-panel" >
17354 <div class="section-header sx-ea7dba9f" >LCOV Coverage Summary</div>
17355 <div class="cov-gauge-row" id="cov-gauges">
17356 <div class="cov-gauge-card">
17357 <div class="cov-gauge-label">Line Coverage</div>
17358 <div class="cov-gauge-val sx-d63f7cf0" id="cov-line-val" >{cov_line_pct_str}%</div>
17359 <div class="cov-gauge-track"><div id="cov-line-bar" class="cov-gauge-fill" data-sx-style="width:{cov_line_pct_str}%;background:#2a6846;"></div></div>
17360 <div class="cov-gauge-sub">Lines hit / instrumented</div>
17361 <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>
17362 </div>
17363 <div class="cov-gauge-card">
17364 <div class="cov-gauge-label">Function Coverage</div>
17365 <div class="cov-gauge-val sx-e1c6661f" id="cov-fn-val" >{cov_fn_pct_str}%</div>
17366 <div class="cov-gauge-track"><div id="cov-fn-bar" class="cov-gauge-fill" data-sx-style="width:{cov_fn_pct_str}%;background:#1a6b96;"></div></div>
17367 <div class="cov-gauge-sub">Functions hit / found</div>
17368 <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>
17369 </div>
17370 <div class="cov-gauge-card">
17371 <div class="cov-gauge-label">Branch Coverage</div>
17372 <div class="cov-gauge-val sx-e35e33d5" id="cov-branch-val" >{cov_branch_pct_str}%</div>
17373 <div class="cov-gauge-track"><div id="cov-branch-bar" class="cov-gauge-fill" data-sx-style="width:{cov_branch_pct_str}%;background:#7a4fa0;"></div></div>
17374 <div class="cov-gauge-sub">Branches hit / found</div>
17375 <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>
17376 </div>
17377 </div>
17378 <div class="chart-row">
17379 <div class="chart-box">
17380 <div class="chart-box-title">Line Coverage % by Language</div>
17381 <div class="chart-canvas-wrap"><canvas id="canvas-cov"></canvas></div>
17382 </div>
17383 <div class="chart-box">
17384 <div class="chart-box-title">Coverage Tier Distribution</div>
17385 <div class="chart-canvas-wrap sx-5d020860" ><canvas id="canvas-cov-tiers"></canvas></div>
17386 </div>
17387 </div>
17388
17389 <div class="section-header sx-04bbec5e" >Coverage File Detail</div>
17390 <p class="muted sx-16dbf2a3" >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>
17391 <div class="cov-file-toolbar">
17392 <div class="cov-filter-tabs" id="cov-filter-tabs">
17393 <button class="cov-tab active" data-tier="all">All</button>
17394 <button class="cov-tab" data-tier="zero">Uncovered (0%)</button>
17395 <button class="cov-tab" data-tier="low">Low (<50%)</button>
17396 <button class="cov-tab" data-tier="mid">Moderate (50-79%)</button>
17397 <button class="cov-tab" data-tier="high">High (≥80%)</button>
17398 </div>
17399 <input type="search" id="cov-file-search" class="cov-file-search" placeholder="Filter by filename…">
17400 </div>
17401 <div class="sx-fbff25e9" >
17402 <table class="data-table" id="cov-file-table">
17403 <thead><tr>
17404 <th>File</th>
17405 <th>Lang</th>
17406 <th class="num">Line %</th>
17407 <th class="num">Lines Hit / Found</th>
17408 <th class="num">Fn %</th>
17409 <th class="num">Fns Hit / Found</th>
17410 </tr></thead>
17411 <tbody id="cov-file-tbody"></tbody>
17412 </table>
17413 </div>
17414 <div class="sx-e5565aef" id="cov-file-empty" >No files match the current filter.</div>
17415 <div class="sx-6d1fab35" id="cov-file-count" ></div>
17416 </div>
17417
17418 </div>
17419
17420 <footer class="site-footer">
17421 local code analysis - metrics, history and reports
17422 · <em class="footer-mode sx-e01b0d98" id="footer-mode" >oxide-sloc v{version} — Mode: Server</em>
17423 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
17424 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
17425 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
17426 · <a href="/report-bug" rel="noopener">Report a Bug</a> · <a href="/api-docs" rel="noopener">REST API</a>
17427 </footer>
17428
17429 <script nonce="{nonce}">
17430 (function() {{
17431 // Theme
17432 var b = document.body;
17433 try {{ var s = localStorage.getItem('oxide-theme'); if (s === 'dark') b.classList.add('dark-theme'); }} catch(e) {{}}
17434 var tgl = document.getElementById('theme-toggle');
17435 if (tgl) tgl.addEventListener('click', function() {{
17436 var d = b.classList.toggle('dark-theme');
17437 try {{ localStorage.setItem('oxide-theme', d ? 'dark' : 'light'); }} catch(e) {{}}
17438 }});
17439
17440 // Watermarks
17441 (function() {{
17442 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
17443 if (!wms.length) return;
17444 var placed = [];
17445 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;}}
17446 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];}}
17447 var half=Math.floor(wms.length/2);
17448 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;}});
17449 }})();
17450
17451 // Code particles
17452 (function() {{
17453 var container = document.getElementById('code-particles');
17454 if (!container) return;
17455 var snippets = ['NUM sloc','fn analyze()','code_lines','0 mixed','blanks: NUM','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','NUM physical','files: NUM','NUM comments','cargo build','Ok(run)','Vec<GEN>','match lang','fn main()','.rs .go .py','sloc_core','render_html','NUM code','HashMap<GEN, GN2>','Option<GEN>','&str','author AUT','last_change','ratio 0.CPX','complexity CPX','tokens: NUM','?; // try','let owner','commits: NUM','#[tokio::test]','assert_eq!','PCT% cover','blame AUT','churn PCT%','HEAD~CPX','dedup PCT%','cyclomatic CPX','impl Iterator','.await','+NUM -NM2','ownership','Span<GEN>','pub struct','trait Lang','unsafe','mixed: NUM','anyhow::Result','fn detect()','PathBuf','+NUM loc','coverage PCT%','physical: NUM','ULOC NUM','dup PCT%','let mut run']; var __GT=['Author','String','Token','FileRecord','Lang','u32','usize','PathBuf','Commit','Line','Span','Metric','Record','u64','char','Utf8','Ratio','Blame']; var __AN=['nima','core','web','cli','git','lang','ci','bot','sloc'];
17456 for (var i = 0; i < 42; i++) {{
17457 (function(idx) {{
17458 var el = document.createElement('span');
17459 el.className = 'code-particle';
17460 el.textContent = snippets[idx % snippets.length].split('NUM').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('NM2').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('GEN').join(__GT[Math.floor(Math.random()*__GT.length)]).split('GN2').join(__GT[Math.floor(Math.random()*__GT.length)]).split('AUT').join(__AN[Math.floor(Math.random()*__AN.length)]).split('CPX').join(''+Math.floor(Math.random()*40+1)).split('PCT').join(''+Math.floor(Math.random()*44+1));
17461 var left = Math.random() * 94 + 2, top = Math.random() * 88 + 6;
17462 var dur = (Math.random() * 10 + 9).toFixed(1), delay = (Math.random() * 18).toFixed(1);
17463 var rot = (Math.random() * 26 - 13).toFixed(1), op = (Math.random() * 0.108 + 0.072).toFixed(3);
17464 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';
17465 container.appendChild(el);
17466 }})(i);
17467 }}
17468 }})();
17469
17470 // Settings modal
17471 (function() {{
17472 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'}}];
17473 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);}});}}
17474 try{{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){{ap(sv);}}else{{ap(S[0]);}}}}catch(e){{ap(S[0]);}}
17475 var btn=document.getElementById('settings-btn');if(!btn)return;
17476 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
17477 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 class="sx-dec3b281" ><div class="settings-modal-label sx-c500155b" >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>';
17478 document.body.appendChild(m);
17479 var g=document.getElementById('scheme-grid');
17480 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);}});
17481 var cl=document.getElementById('settings-close');
17482 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');}});
17483 if(cl)cl.addEventListener('click',function(){{m.classList.remove('open');}});
17484 document.addEventListener('click',function(e){{if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');}});
17485 }})();
17486
17487 // Watched folder picker
17488 (function(){{
17489 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');}};
17490 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);
17491 }})();
17492 (function() {{
17493 var btn = document.getElementById('add-watched-btn');
17494 if (!btn) return;
17495 btn.addEventListener('click', function() {{
17496 fetch('/pick-directory?kind=reports')
17497 .then(function(r) {{ return r.ok ? r.json() : {{ cancelled: true }}; }})
17498 .then(function(data) {{
17499 if (!data.cancelled && data.selected_path) {{
17500 var form = document.createElement('form');
17501 form.method = 'POST';
17502 form.action = '/watched-dirs/add';
17503 var ri = document.createElement('input');
17504 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
17505 var fi = document.createElement('input');
17506 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
17507 form.appendChild(ri); form.appendChild(fi);
17508 document.body.appendChild(form);
17509 if (window.__scanOverlay) window.__scanOverlay();
17510 form.submit();
17511 }}
17512 }})
17513 .catch(function(e) {{ alert('Could not open folder picker: ' + e); }});
17514 }});
17515 }})();
17516 }})();
17517 </script>
17518
17519 <script src="/static/chart.js" nonce="{nonce}"></script>
17520 <script nonce="{nonce}">
17521 (function() {{
17522 var SCOPE_DATA = {scope_data_json};
17523 var currentRoot = '__all__';
17524 var currentSub = '';
17525 var testsChart = null, densityChart = null, covChart = null, tierChart = null, trendChart = null;
17526 var assertionsChart = null, suitesChart = null, filesChart = null, compositionChart = null;
17527 var ALL_CHARTS = [];
17528 var currentLangTests = [];
17529 var currentTrendPts = [];
17530
17531 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();}}
17532 function fmtFull(n){{return Number(n).toLocaleString();}}
17533 function isDark(){{return document.body.classList.contains('dark-theme');}}
17534 function clr(){{return isDark()?'rgba(245,236,230,0.12)':'rgba(67,52,45,0.10)';}}
17535 function txtClr(){{return isDark()?'#c7b7aa':'#7b675b';}}
17536 var PALETTE=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#D0743C','#5BA8A0'];
17537
17538 function makeDlPlugin(fmtFn, anchor) {{
17539 return {{
17540 afterDatasetsDraw: function(chart) {{
17541 var ctx = chart.ctx;
17542 var tc = txtClr();
17543 chart.data.datasets.forEach(function(ds, di) {{
17544 var meta = chart.getDatasetMeta(di);
17545 meta.data.forEach(function(el, idx) {{
17546 var label = fmtFn(ds.data[idx], di, idx);
17547 if (label == null || label === '') return;
17548 ctx.save();
17549 ctx.font = '600 11px Inter,ui-sans-serif,sans-serif';
17550 ctx.fillStyle = tc;
17551 if (anchor === 'top') {{
17552 ctx.textAlign = 'center';
17553 ctx.textBaseline = 'bottom';
17554 ctx.fillText(String(label), el.x, el.y - 5);
17555 }} else {{
17556 ctx.textAlign = 'left';
17557 ctx.textBaseline = 'middle';
17558 ctx.fillText(String(label), el.x + 5, el.y);
17559 }}
17560 ctx.restore();
17561 }});
17562 }});
17563 }}
17564 }};
17565 }}
17566
17567 // Cursor: pointer over chart data, default over empty chart area.
17568 function chartCursor(e, els) {{
17569 var t = e.native && e.native.target;
17570 if (t) t.style.cursor = els.length ? 'pointer' : 'default';
17571 }}
17572 Chart.defaults.onHover = chartCursor; // applies to every chart on this page
17573
17574 // ── Global bar hover emphasis ──────────────────────────────────────────────
17575 // Doughnuts pop via hoverOffset; bars had no per-bar hover feedback (fading the
17576 // *other* bars does nothing when there is only one). Give every bar chart a
17577 // built-in "pop": the hovered bar brightens, lifts with a rounded outline, and
17578 // animates via the fast active transition. Applied globally through a plugin so
17579 // it covers all current and future bar charts on the page.
17580 function tmLighten(c, amt) {{
17581 if (typeof c === 'string' && c.charAt(0) === '#' && c.length === 7) {{
17582 var n = parseInt(c.slice(1), 16), r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
17583 r = Math.round(r + (255 - r) * amt);
17584 g = Math.round(g + (255 - g) * amt);
17585 b = Math.round(b + (255 - b) * amt);
17586 return 'rgb(' + r + ',' + g + ',' + b + ')';
17587 }}
17588 return c;
17589 }}
17590 var tmBarHoverEmphasis = {{
17591 id: 'tmBarHoverEmphasis',
17592 beforeInit: function(chart) {{
17593 if (!chart.config || chart.config.type !== 'bar') return;
17594 (chart.data.datasets || []).forEach(function(ds) {{
17595 var bg = ds.backgroundColor;
17596 if (ds.hoverBackgroundColor == null) {{
17597 ds.hoverBackgroundColor = Array.isArray(bg)
17598 ? bg.map(function(c) {{ return tmLighten(c, 0.24); }})
17599 : tmLighten(bg, 0.24);
17600 }}
17601 if (ds.hoverBorderColor == null) {{
17602 ds.hoverBorderColor = isDark() ? 'rgba(245,236,230,0.9)' : 'rgba(67,52,45,0.82)';
17603 }}
17604 if (ds.hoverBorderWidth == null) ds.hoverBorderWidth = 3;
17605 }});
17606 }}
17607 }};
17608 Chart.register(tmBarHoverEmphasis);
17609 // Quick, smooth tween when a bar enters/leaves the hovered (active) state.
17610 try {{
17611 Chart.defaults.transitions.active = Chart.defaults.transitions.active || {{}};
17612 Chart.defaults.transitions.active.animation = Chart.defaults.transitions.active.animation || {{}};
17613 Chart.defaults.transitions.active.animation.duration = 260;
17614 }} catch (e) {{}}
17615
17616 // Plugin: draws % labels inside each doughnut slice.
17617 var donutPctPlugin = {{
17618 afterDatasetsDraw: function(chart) {{
17619 var ctx = chart.ctx;
17620 chart.data.datasets.forEach(function(ds, di) {{
17621 var meta = chart.getDatasetMeta(di);
17622 if (meta.hidden) return;
17623 var total = 0;
17624 for (var k = 0; k < ds.data.length; k++) total += (ds.data[k] || 0);
17625 if (!total) return;
17626 meta.data.forEach(function(arc, i) {{
17627 if (arc.hidden) return;
17628 var val = ds.data[i] || 0;
17629 var pct = val / total * 100;
17630 if (pct < 3) return;
17631 var midAngle = (arc.startAngle + arc.endAngle) / 2;
17632 var midR = (arc.innerRadius + arc.outerRadius) / 2;
17633 var tx = arc.x + midR * Math.cos(midAngle);
17634 var ty = arc.y + midR * Math.sin(midAngle);
17635 ctx.save();
17636 ctx.textAlign = 'center';
17637 ctx.textBaseline = 'middle';
17638 ctx.font = 'bold 13px Inter,ui-sans-serif,sans-serif';
17639 ctx.shadowColor = 'rgba(0,0,0,0.45)';
17640 ctx.shadowBlur = 3;
17641 ctx.fillStyle = '#fff';
17642 ctx.fillText(pct.toFixed(0) + '%', tx, ty);
17643 ctx.restore();
17644 }});
17645 }});
17646 }}
17647 }};
17648
17649 function makeTmOverlay(title, subtitle, h) {{
17650 var overlay = document.createElement('div');
17651 overlay.className = 'chart-modal-overlay';
17652 var maxH = Math.max(400, Math.floor(window.innerHeight * 0.82) - 130);
17653 var ch = Math.min(h || 560, maxH);
17654 var subHtml = subtitle ? '<span class="chart-modal-subtitle">' + subtitle + '</span>' : '';
17655 overlay.innerHTML = '<div class="chart-modal sx-02e8e0b3" ><button class="chart-modal-close" aria-label="Close">×</button><span class="chart-modal-title">' + title + '</span>' + subHtml + '<div data-sx-style="position:relative;width:100%;height:' + ch + 'px;"><canvas id="tm-modal-canvas"></canvas></div></div>';
17656 document.body.appendChild(overlay);
17657 overlay.querySelector('.chart-modal-close').addEventListener('click', function(){{ document.body.removeChild(overlay); }});
17658 overlay.addEventListener('click', function(e){{ if (e.target === overlay) document.body.removeChild(overlay); }});
17659 return document.getElementById('tm-modal-canvas');
17660 }}
17661
17662 function getDataset() {{
17663 var r = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
17664 if (currentSub && r.submodules && r.submodules[currentSub]) return r.submodules[currentSub];
17665 return r;
17666 }}
17667 function destroyChart(c) {{ if (c) {{ var idx = ALL_CHARTS.indexOf(c); if (idx >= 0) ALL_CHARTS.splice(idx, 1); c.destroy(); }} return null; }}
17668
17669 function showNoData(id, show) {{
17670 var el = document.getElementById(id);
17671 if (!el) return;
17672 var wrap = el.previousElementSibling;
17673 el.style.display = show ? '' : 'none';
17674 if (wrap && wrap.classList.contains('chart-canvas-wrap')) wrap.style.display = show ? 'none' : '';
17675 }}
17676
17677 // Shared hover treatment for every single-series bar/doughnut chart on this page:
17678 // emphasise the hovered bar/arc and fade the rest, mirroring the highlight+fade
17679 // treatment used by the language charts on the scan results page.
17680 function tmFadeColor(c) {{
17681 if (typeof c === 'string' && c.charAt(0) === '#' && c.length === 7) return c + '3D';
17682 return c;
17683 }}
17684 function tmApplyFade(chart, activeIdx) {{
17685 var ds = chart.data.datasets[0];
17686 if (!ds._baseBg) ds._baseBg = ds.backgroundColor.slice();
17687 if (activeIdx == null) {{
17688 ds.backgroundColor = ds._baseBg.slice();
17689 }} else {{
17690 ds.backgroundColor = ds._baseBg.map(function(c, i) {{
17691 return i === activeIdx ? ds._baseBg[i] : tmFadeColor(ds._baseBg[i]);
17692 }});
17693 }}
17694 }}
17695 function tmFadeHover(e, active, chart) {{
17696 var t = e.native && e.native.target;
17697 if (t) t.style.cursor = active.length ? 'pointer' : 'default';
17698 var idx = active.length ? active[0].index : null;
17699 if (chart._fadeIdx === idx) return;
17700 chart._fadeIdx = idx;
17701 tmApplyFade(chart, idx);
17702 // 'active' mode tweens the fade + the hovered bar's pop via the fast active
17703 // transition (doughnuts keep their own hoverOffset motion regardless).
17704 chart.update('active');
17705 }}
17706 // Legend hover on a doughnut should highlight+fade exactly like hovering the arc.
17707 function tmDoughnutLegendHover(e, item, leg) {{
17708 var ch = leg.chart;
17709 var t = e.native && e.native.target;
17710 if (t) t.style.cursor = 'pointer';
17711 ch._fadeIdx = item.index;
17712 ch.setActiveElements([{{ datasetIndex: 0, index: item.index }}]);
17713 ch.tooltip.setActiveElements([{{ datasetIndex: 0, index: item.index }}], {{ x: 0, y: 0 }});
17714 tmApplyFade(ch, item.index);
17715 ch.update();
17716 }}
17717 function tmDoughnutLegendLeave(e, item, leg) {{
17718 var ch = leg.chart;
17719 var t = e.native && e.native.target;
17720 if (t) t.style.cursor = 'default';
17721 ch._fadeIdx = null;
17722 ch.setActiveElements([]);
17723 ch.tooltip.setActiveElements([], {{}});
17724 tmApplyFade(ch, null);
17725 ch.update('none');
17726 }}
17727
17728 function renderTestCharts(D) {{
17729 currentLangTests = D || [];
17730 testsChart = destroyChart(testsChart);
17731 densityChart = destroyChart(densityChart);
17732 if (!D || !D.length) {{
17733 showNoData('no-data-tests', true);
17734 showNoData('no-data-density', true);
17735 return;
17736 }}
17737 showNoData('no-data-tests', false);
17738 showNoData('no-data-density', false);
17739 var top15 = D.slice(0, 15);
17740 var canvas1 = document.getElementById('canvas-tests');
17741 if (canvas1) {{
17742 testsChart = new Chart(canvas1, {{
17743 type: 'bar',
17744 data: {{
17745 labels: top15.map(function(d){{ return d.lang; }}),
17746 datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
17747 }},
17748 options: {{
17749 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
17750 layout: {{ padding: {{ right: 64 }} }},
17751 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
17752 scales: {{
17753 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
17754 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
17755 }}
17756 }},
17757 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
17758 }});
17759 ALL_CHARTS.push(testsChart);
17760 }}
17761 var topD = top15.slice().sort(function(a,b){{ return b.density - a.density; }});
17762 var canvas2 = document.getElementById('canvas-density');
17763 if (canvas2) {{
17764 densityChart = new Chart(canvas2, {{
17765 type: 'bar',
17766 data: {{
17767 labels: topD.map(function(d){{ return d.lang; }}),
17768 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 }}]
17769 }},
17770 options: {{
17771 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
17772 layout: {{ padding: {{ right: 64 }} }},
17773 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
17774 scales: {{
17775 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v.toFixed(1); }} }} }},
17776 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
17777 }}
17778 }},
17779 plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
17780 }});
17781 ALL_CHARTS.push(densityChart);
17782 }}
17783 }}
17784
17785 function renderAssertionsChart(D) {{
17786 assertionsChart = destroyChart(assertionsChart);
17787 if (!D || !D.length) {{ showNoData('no-data-assertions', true); return; }}
17788 var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
17789 var canvas = document.getElementById('canvas-assertions');
17790 if (!canvas || !top15.length) {{ showNoData('no-data-assertions', true); return; }}
17791 showNoData('no-data-assertions', false);
17792 assertionsChart = new Chart(canvas, {{
17793 type: 'bar',
17794 data: {{
17795 labels: top15.map(function(d){{ return d.lang; }}),
17796 datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
17797 }},
17798 options: {{
17799 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
17800 layout: {{ padding: {{ right: 64 }} }},
17801 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
17802 scales: {{
17803 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
17804 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
17805 }}
17806 }},
17807 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
17808 }});
17809 ALL_CHARTS.push(assertionsChart);
17810 }}
17811
17812 function renderSuitesChart(D) {{
17813 suitesChart = destroyChart(suitesChart);
17814 if (!D || !D.length) {{ showNoData('no-data-suites', true); return; }}
17815 var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
17816 var canvas = document.getElementById('canvas-suites');
17817 if (!canvas || !top15.length) {{ showNoData('no-data-suites', true); return; }}
17818 showNoData('no-data-suites', false);
17819 suitesChart = new Chart(canvas, {{
17820 type: 'bar',
17821 data: {{
17822 labels: top15.map(function(d){{ return d.lang; }}),
17823 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 }}]
17824 }},
17825 options: {{
17826 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
17827 layout: {{ padding: {{ right: 64 }} }},
17828 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
17829 scales: {{
17830 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }},
17831 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
17832 }}
17833 }},
17834 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
17835 }});
17836 ALL_CHARTS.push(suitesChart);
17837 }}
17838
17839 function renderFilesChart(totals) {{
17840 filesChart = destroyChart(filesChart);
17841 var canvas = document.getElementById('canvas-files');
17842 if (!canvas) return;
17843 var testF = totals.test_files || 0;
17844 var totalF = totals.total_files || 0;
17845 var nonTest = Math.max(0, totalF - testF);
17846 if (totalF === 0) {{ showNoData('no-data-files', true); return; }}
17847 showNoData('no-data-files', false);
17848 var dark = isDark();
17849 filesChart = new Chart(canvas, {{
17850 type: 'doughnut',
17851 data: {{
17852 labels: ['Test Files', 'Non-Test Files'],
17853 datasets: [{{ data: [testF, nonTest], backgroundColor: ['#C45C10', dark ? '#524238' : '#e6d0bf'], borderWidth: 2, borderColor: dark ? '#1e1e1e' : '#f5efe8', hoverOffset: 14 }}]
17854 }},
17855 options: {{
17856 responsive: true, maintainAspectRatio: false, cutout: '62%',
17857 onHover: tmFadeHover,
17858 plugins: {{
17859 legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 16,
17860 generateLabels: function(chart) {{
17861 var ds = chart.data.datasets[0];
17862 var tot = ds.data.reduce(function(a,b){{return a+(b||0);}}, 0);
17863 return chart.data.labels.map(function(lbl, i) {{
17864 var val = ds.data[i] || 0;
17865 var pct = tot > 0 ? (val / tot * 100).toFixed(0) : '0';
17866 return {{
17867 text: lbl + ' ' + fmtFull(val) + ' (' + pct + '%)',
17868 fillStyle: ds.backgroundColor[i],
17869 strokeStyle: ds.borderColor,
17870 lineWidth: ds.borderWidth,
17871 hidden: false,
17872 index: i,
17873 datasetIndex: 0
17874 }};
17875 }});
17876 }}
17877 }},
17878 onHover: tmDoughnutLegendHover,
17879 onLeave: tmDoughnutLegendLeave
17880 }},
17881 tooltip: {{ callbacks: {{ label: function(ctx) {{
17882 var v = ctx.parsed, pct = totalF > 0 ? (v / totalF * 100).toFixed(1) : '0';
17883 return ' ' + fmtFull(v) + ' files (' + pct + '%)';
17884 }} }} }}
17885 }}
17886 }},
17887 plugins: [donutPctPlugin]
17888 }});
17889 ALL_CHARTS.push(filesChart);
17890 }}
17891
17892 function renderCompositionChart(totals) {{
17893 compositionChart = destroyChart(compositionChart);
17894 var canvas = document.getElementById('canvas-composition');
17895 if (!canvas) return;
17896 var tc = totals.test_count || 0, ac = totals.assertions || 0, sc = totals.suites || 0;
17897 if (tc === 0 && ac === 0 && sc === 0) {{ showNoData('no-data-composition', true); return; }}
17898 showNoData('no-data-composition', false);
17899 compositionChart = new Chart(canvas, {{
17900 type: 'bar',
17901 data: {{
17902 labels: ['Test Functions', 'Assertions', 'Test Suites'],
17903 datasets: [{{ label: 'Count', data: [tc, ac, sc], backgroundColor: ['#C45C10', '#2A6846', '#4472C4'], borderRadius: 6 }}]
17904 }},
17905 options: {{
17906 responsive: true, maintainAspectRatio: false,
17907 onHover: tmFadeHover,
17908 layout: {{ padding: {{ top: 22 }} }},
17909 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.y); }} }} }} }},
17910 scales: {{
17911 x: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }},
17912 y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
17913 }}
17914 }},
17915 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top')]
17916 }});
17917 ALL_CHARTS.push(compositionChart);
17918 }}
17919
17920 function renderCovCharts(covD, tiers) {{
17921 covChart = destroyChart(covChart);
17922 tierChart = destroyChart(tierChart);
17923 var covCanvas = document.getElementById('canvas-cov');
17924 if (covCanvas && covD && covD.length) {{
17925 covChart = new Chart(covCanvas, {{
17926 type: 'bar',
17927 data: {{
17928 labels: covD.map(function(d){{ return d.lang; }}),
17929 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 }}]
17930 }},
17931 options: {{
17932 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
17933 layout: {{ padding: {{ right: 52 }} }},
17934 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + ctx.parsed.x.toFixed(1) + '%'; }} }} }} }},
17935 scales: {{
17936 x: {{ min: 0, max: 100, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return v + '%'; }} }} }},
17937 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:11}} }} }}
17938 }}
17939 }},
17940 plugins: [makeDlPlugin(function(v){{ return Number(v).toFixed(1) + '%'; }}, 'end')]
17941 }});
17942 ALL_CHARTS.push(covChart);
17943 }}
17944 var tierCanvas = document.getElementById('canvas-cov-tiers');
17945 if (tierCanvas && tiers) {{
17946 var total = (tiers.high || 0) + (tiers.mid || 0) + (tiers.low || 0);
17947 tierChart = new Chart(tierCanvas, {{
17948 type: 'doughnut',
17949 data: {{
17950 labels: ['High (\u226580%)', 'Moderate (50\u201379%)', 'Low (<50%)'],
17951 datasets: [{{ data: [tiers.high || 0, tiers.mid || 0, tiers.low || 0], backgroundColor: ['#2A6846', '#D4A017', '#B23030'], borderWidth: 2, borderColor: isDark() ? '#1e1e1e' : '#f5efe8', hoverOffset: 14 }}]
17952 }},
17953 options: {{
17954 responsive: true, maintainAspectRatio: false, cutout: '62%',
17955 onHover: tmFadeHover,
17956 plugins: {{
17957 legend: {{ position: 'right', labels: {{ color: txtClr(), font: {{size:12}}, padding: 14 }},
17958 onHover: tmDoughnutLegendHover,
17959 onLeave: tmDoughnutLegendLeave
17960 }},
17961 tooltip: {{ callbacks: {{ label: function(ctx) {{
17962 var v = ctx.parsed, pct = total > 0 ? (v / total * 100).toFixed(1) : '0';
17963 return ' ' + v + ' file' + (v !== 1 ? 's' : '') + ' (' + pct + '%)';
17964 }} }} }}
17965 }}
17966 }},
17967 plugins: [donutPctPlugin]
17968 }});
17969 ALL_CHARTS.push(tierChart);
17970 }}
17971 }}
17972
17973 function buildLangTable(D) {{
17974 var tbody = document.getElementById('lang-tbody');
17975 if (!tbody) return;
17976 if (!D || !D.length) {{
17977 tbody.innerHTML = '<tr><td class="sx-f3ab2b4c" colspan="8" >No test definitions detected. Run a scan on a project with test files.</td></tr>';
17978 return;
17979 }}
17980 var maxDensity = Math.max.apply(null, D.map(function(d){{ return d.density; }})) || 1;
17981 tbody.innerHTML = D.map(function(d) {{
17982 var barW = Math.round(d.density / maxDensity * 120);
17983 return '<tr>' +
17984 '<td><strong>' + d.lang + '</strong></td>' +
17985 '<td class="num">' + fmtFull(d.tests) + '</td>' +
17986 '<td class="num">' + fmtFull(d.assertions || 0) + '</td>' +
17987 '<td class="num">' + fmtFull(d.suites || 0) + '</td>' +
17988 '<td class="num">' + fmtFull(d.code) + '</td>' +
17989 '<td class="num">' + fmtFull(d.files) + '</td>' +
17990 '<td class="num">' + d.density.toFixed(2) + '</td>' +
17991 '<td><div class="density-bar-wrap"><div class="density-bar" data-sx-style="width:' + barW + 'px;"></div></div></td>' +
17992 '</tr>';
17993 }}).join('');
17994 }}
17995
17996 var covFileData = [];
17997 var covFileTier = 'all';
17998 var covFileSearch = '';
17999
18000 function pctBadge(pct) {{
18001 var color = pct >= 80 ? '#2a6846' : pct >= 50 ? '#b58a00' : '#b23030';
18002 var bg = pct >= 80 ? 'rgba(42,104,70,0.12)' : pct >= 50 ? 'rgba(181,138,0,0.12)' : 'rgba(178,48,48,0.12)';
18003 return '<span class="cov-pct-badge" data-sx-style="background:' + bg + ';color:' + color + ';border:1px solid ' + color + '40;">' + pct.toFixed(1) + '%</span>';
18004 }}
18005
18006 function buildCovFileTable() {{
18007 var tbody = document.getElementById('cov-file-tbody');
18008 var empty = document.getElementById('cov-file-empty');
18009 var count = document.getElementById('cov-file-count');
18010 if (!tbody) return;
18011 var srch = covFileSearch.toLowerCase();
18012 var filtered = covFileData.filter(function(f) {{
18013 if (covFileTier === 'zero' && f.line_pct > 0) return false;
18014 if (covFileTier === 'low' && (f.line_pct === 0 || f.line_pct >= 50)) return false;
18015 if (covFileTier === 'mid' && (f.line_pct < 50 || f.line_pct >= 80)) return false;
18016 if (covFileTier === 'high' && f.line_pct < 80) return false;
18017 if (srch && f.rel.toLowerCase().indexOf(srch) < 0) return false;
18018 return true;
18019 }});
18020 if (!filtered.length) {{
18021 tbody.innerHTML = '';
18022 if (empty) empty.style.display = '';
18023 if (count) count.textContent = '';
18024 return;
18025 }}
18026 if (empty) empty.style.display = 'none';
18027 var shown = Math.min(filtered.length, 500);
18028 if (count) count.textContent = shown + ' of ' + filtered.length + ' file' + (filtered.length !== 1 ? 's' : '') + (filtered.length > 500 ? ' (showing first 500)' : '');
18029 tbody.innerHTML = filtered.slice(0, 500).map(function(f) {{
18030 var fnCol = f.fn_pct < 0
18031 ? '<td class="num sx-eb12fd52" >\u2014</td><td class="num sx-eb12fd52" >\u2014</td>'
18032 : '<td class="num">' + pctBadge(f.fn_pct) + '</td><td class="num sx-eb12fd52" >' + f.fhit + ' / ' + f.ffound + '</td>';
18033 return '<tr>' +
18034 '<td class="cov-file-path" title="' + f.rel.replace(/"/g, '"') + '">' + f.rel + '</td>' +
18035 '<td class="sx-db9c1998" >' + f.lang + '</td>' +
18036 '<td class="num">' + pctBadge(f.line_pct) + '</td>' +
18037 '<td class="num sx-eb12fd52" >' + f.lhit + ' / ' + f.lfound + '</td>' +
18038 fnCol +
18039 '</tr>';
18040 }}).join('');
18041 }}
18042
18043 (function() {{
18044 var tabs = document.getElementById('cov-filter-tabs');
18045 if (tabs) {{
18046 tabs.addEventListener('click', function(e) {{
18047 var btn = e.target.closest('.cov-tab');
18048 if (!btn) return;
18049 Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(t) {{ t.classList.remove('active'); }});
18050 btn.classList.add('active');
18051 covFileTier = btn.getAttribute('data-tier');
18052 buildCovFileTable();
18053 }});
18054 }}
18055 var srch = document.getElementById('cov-file-search');
18056 if (srch) {{
18057 srch.addEventListener('input', function() {{
18058 covFileSearch = this.value;
18059 buildCovFileTable();
18060 }});
18061 }}
18062 }})();
18063
18064 function updateCovGauges(t) {{
18065 var lp = t.cov_line || '0', fp = t.cov_fn || '0', bp = t.cov_branch || '0';
18066 var el;
18067 if ((el = document.getElementById('cov-line-val'))) el.textContent = lp + '%';
18068 if ((el = document.getElementById('cov-line-bar'))) el.style.width = lp + '%';
18069 if ((el = document.getElementById('cov-fn-val'))) el.textContent = fp + '%';
18070 if ((el = document.getElementById('cov-fn-bar'))) el.style.width = fp + '%';
18071 if ((el = document.getElementById('cov-branch-val'))) el.textContent = bp + '%';
18072 if ((el = document.getElementById('cov-branch-bar'))) el.style.width = bp + '%';
18073 }}
18074
18075 function applyScope() {{
18076 var d = getDataset();
18077 var t = d.totals;
18078 var el;
18079 if ((el = document.getElementById('chip-total'))) el.textContent = fmt(t.test_count);
18080 if ((el = document.getElementById('chip-total-exact'))) el.textContent = fmtFull(t.test_count);
18081 if ((el = document.getElementById('chip-assertions'))) el.textContent = fmt(t.assertions);
18082 if ((el = document.getElementById('chip-assertions-exact'))) el.textContent = fmtFull(t.assertions);
18083 if ((el = document.getElementById('chip-suites'))) el.textContent = fmt(t.suites);
18084 if ((el = document.getElementById('chip-test-files'))) el.textContent = fmt(t.test_files) + ' / ' + fmt(t.total_files);
18085 if ((el = document.getElementById('chip-test-files-exact'))) el.textContent = fmtFull(t.test_files) + ' / ' + fmtFull(t.total_files);
18086 if ((el = document.getElementById('chip-density'))) el.textContent = t.density_str;
18087 if ((el = document.getElementById('chip-most'))) el.textContent = t.most_tested;
18088 if ((el = document.getElementById('chip-langs'))) el.textContent = fmt(t.langs_with_tests);
18089 if ((el = document.getElementById('chip-cov-pct'))) el.textContent = t.cov_line + '%';
18090 renderTestCharts(d.lang_tests);
18091 renderAssertionsChart(d.lang_tests);
18092 renderSuitesChart(d.lang_tests);
18093 renderFilesChart(t);
18094 renderCompositionChart(t);
18095 buildLangTable(d.lang_tests);
18096 var covPanel = document.getElementById('cov-panel');
18097 if (covPanel) covPanel.style.display = d.has_coverage ? '' : 'none';
18098 if (d.has_coverage) {{
18099 renderCovCharts(d.cov, d.cov_tiers);
18100 updateCovGauges(t);
18101 covFileData = d.file_cov || [];
18102 covFileTier = 'all';
18103 covFileSearch = '';
18104 var tabs = document.getElementById('cov-filter-tabs');
18105 if (tabs) Array.prototype.forEach.call(tabs.querySelectorAll('.cov-tab'), function(tb) {{ tb.classList.toggle('active', tb.getAttribute('data-tier') === 'all'); }});
18106 var srch = document.getElementById('cov-file-search');
18107 if (srch) srch.value = '';
18108 buildCovFileTable();
18109 }}
18110 loadTrend();
18111 }}
18112
18113 // Populate scope-root-sel from SCOPE_DATA keys
18114 (function() {{
18115 var sel = document.getElementById('scope-root-sel');
18116 if (!sel) return;
18117 Object.keys(SCOPE_DATA).forEach(function(k) {{
18118 if (k === '__all__') return;
18119 var o = document.createElement('option'); o.value = k; o.textContent = k; sel.appendChild(o);
18120 }});
18121 }})();
18122
18123 document.getElementById('scope-root-sel').addEventListener('change', function() {{
18124 currentRoot = this.value;
18125 currentSub = '';
18126 var rootData = SCOPE_DATA[currentRoot] || SCOPE_DATA['__all__'];
18127 var subNames = rootData && rootData.submodules ? Object.keys(rootData.submodules) : [];
18128 var subWrap = document.getElementById('scope-sub-wrap');
18129 var subSel = document.getElementById('scope-sub-sel');
18130 subSel.innerHTML = '<option value="">Entire project</option>';
18131 if (subNames.length) {{
18132 subNames.forEach(function(s) {{ var o = document.createElement('option'); o.value = s; o.textContent = s; subSel.appendChild(o); }});
18133 subWrap.style.display = 'flex';
18134 }} else {{
18135 subWrap.style.display = 'none';
18136 }}
18137 applyScope();
18138 }});
18139
18140 document.getElementById('scope-sub-sel').addEventListener('change', function() {{
18141 currentSub = this.value;
18142 applyScope();
18143 }});
18144
18145 var allTrendData = [];
18146
18147 var TM_Y_META = {{
18148 test_count: {{ label: 'Test Definitions', color: '#C45C10', tooltip: ' test defs' }},
18149 code_lines: {{ label: 'Code Lines', color: '#2A6846', tooltip: ' code lines' }}
18150 }};
18151
18152 // Parse a hex color (#RRGGBB) into "r,g,b" for building rgba() gradient stops.
18153 function hexRgb(hex) {{
18154 var h = String(hex).replace('#', '');
18155 if (h.length === 3) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2];
18156 var n = parseInt(h, 16);
18157 return ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255);
18158 }}
18159 // Vertical area-fill gradient matching the inline trend chart: fades from a soft
18160 // tint at the top to transparent at the bottom (no flat solid block).
18161 function tmTrendGradient(ctx2, chartArea, color) {{
18162 var rgb = hexRgb(color);
18163 var g = ctx2.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
18164 g.addColorStop(0, 'rgba(' + rgb + ',0.28)');
18165 g.addColorStop(0.5, 'rgba(' + rgb + ',0.10)');
18166 g.addColorStop(1, 'rgba(' + rgb + ',0)');
18167 return g;
18168 }}
18169
18170 // Pixel Y of the trend line at canvas-space x (tension 0 → straight segments,
18171 // so linear interpolation between adjacent points matches the drawn line).
18172 function tmLineYAt(chart, px) {{
18173 var meta = chart.getDatasetMeta(0);
18174 if (!meta || !meta.data || !meta.data.length) return null;
18175 var d = meta.data;
18176 if (px <= d[0].x) return d[0].y;
18177 for (var i = 1; i < d.length; i++) {{
18178 if (px <= d[i].x) {{
18179 var span = d[i].x - d[i - 1].x;
18180 var t = span > 0 ? (px - d[i - 1].x) / span : 0;
18181 return d[i - 1].y + t * (d[i].y - d[i - 1].y);
18182 }}
18183 }}
18184 return d[d.length - 1].y;
18185 }}
18186
18187 // Plugin: only show the tooltip / finger cursor when the pointer is over the
18188 // gradient fill (inside the plot and at/below the line) — never in the empty
18189 // space above the line. Outside the fill we retype the event as 'mouseout' so
18190 // the core interaction dismisses any active tooltip on its own.
18191 var tmFillGuard = {{
18192 id: 'tmFillGuard',
18193 beforeEvent: function(chart, args) {{
18194 var e = args.event;
18195 if (!e || e.type !== 'mousemove') return;
18196 var ca = chart.chartArea;
18197 if (!ca) return;
18198 var inFill = false;
18199 if (e.x >= ca.left && e.x <= ca.right) {{
18200 var ly = tmLineYAt(chart, e.x);
18201 if (ly != null && e.y >= ly - 6 && e.y <= ca.bottom) inFill = true;
18202 }}
18203 if (chart.canvas) chart.canvas.style.cursor = inFill ? 'pointer' : 'default';
18204 if (!inFill) {{ e.type = 'mouseout'; }}
18205 }}
18206 }};
18207
18208 // Single source of truth for the test-metrics trend chart config so the inline
18209 // chart and the Full View modal render identically (straight segments, gradient
18210 // fill, white-ringed points, gradient-only interactivity).
18211 function buildTmTrendConfig(pts, ctrl, meta) {{
18212 return {{
18213 type: 'line',
18214 data: {{
18215 labels: pts.map(function(d){{ return makeTrendLabel(d, ctrl.xMode); }}),
18216 datasets: [{{
18217 label: meta.label,
18218 data: pts.map(function(d){{ return Number(d[ctrl.yKey]) || 0; }}),
18219 borderColor: meta.color,
18220 borderWidth: 2.5,
18221 backgroundColor: function(context) {{
18222 var ca = context.chart.chartArea;
18223 if (!ca) return 'rgba(' + hexRgb(meta.color) + ',0.15)';
18224 return tmTrendGradient(context.chart.ctx, ca, meta.color);
18225 }},
18226 pointBackgroundColor: pts.map(function(d){{ return (d.tags && d.tags.length) ? '#4472C4' : meta.color; }}),
18227 pointBorderColor: '#fff',
18228 pointBorderWidth: 2,
18229 pointRadius: 6,
18230 pointHoverRadius: 9,
18231 pointHoverBorderWidth: 2.5,
18232 fill: true, tension: 0
18233 }}]
18234 }},
18235 options: {{
18236 responsive: true, maintainAspectRatio: false,
18237 layout: {{ padding: {{ top: 22 }} }},
18238 interaction: {{ mode: 'index', intersect: false }},
18239 plugins: {{
18240 legend: {{ display: false }},
18241 tooltip: {{
18242 mode: 'index', intersect: false,
18243 callbacks: {{ label: function(ctx2){{ return ' ' + fmtFull(ctx2.parsed.y) + meta.tooltip; }} }}
18244 }}
18245 }},
18246 scales: {{
18247 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, maxRotation:35 }} }},
18248 y: {{ beginAtZero: true, grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:11}}, callback: function(v){{ return fmtFull(v); }} }} }}
18249 }}
18250 }},
18251 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'top'), tmFillGuard]
18252 }};
18253 }}
18254
18255 function getTrendControls() {{
18256 var ySel = document.getElementById('tm-trend-y');
18257 var xSel = document.getElementById('tm-trend-x');
18258 var sizeSel = document.getElementById('tm-trend-size');
18259 var subSel = document.getElementById('tm-trend-sub');
18260 return {{
18261 yKey: ySel ? ySel.value : 'test_count',
18262 xMode: xSel ? xSel.value : 'commit',
18263 height: sizeSel ? parseInt(sizeSel.value, 10) : 260,
18264 submod: subSel ? subSel.value : ''
18265 }};
18266 }}
18267
18268 function makeTrendLabel(d, xMode) {{
18269 if (xMode === 'commit') {{
18270 return d.commit ? d.commit.substring(0, 7) : (d.run_id_short || '?');
18271 }}
18272 return d.timestamp ? d.timestamp.slice(0, 10) : d.run_id_short;
18273 }}
18274
18275 function buildTrend(data) {{
18276 allTrendData = data || [];
18277 renderTrend();
18278 }}
18279
18280 function renderTrend() {{
18281 var data = allTrendData;
18282 var ctrl = getTrendControls();
18283 var trendCanvas = document.getElementById('canvas-trend');
18284 var trendWrap = document.getElementById('trend-canvas-wrap');
18285 var trendEmpty = document.getElementById('trend-empty');
18286
18287 // Apply chart size
18288 if (trendWrap) trendWrap.style.height = ctrl.height + 'px';
18289
18290 // Filter by submodule if selected (entries from project_label match)
18291 var pts = data.slice().reverse();
18292 if (ctrl.submod) {{
18293 pts = pts.filter(function(d) {{ return d.project_label === ctrl.submod; }});
18294 }}
18295
18296 currentTrendPts = pts;
18297
18298 if (!pts.length) {{
18299 if (trendCanvas) trendCanvas.style.display = 'none';
18300 if (trendEmpty) trendEmpty.style.display = '';
18301 return;
18302 }}
18303 if (trendCanvas) trendCanvas.style.display = '';
18304 if (trendEmpty) trendEmpty.style.display = 'none';
18305
18306 trendChart = destroyChart(trendChart);
18307 if (!trendCanvas) return;
18308
18309 var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
18310
18311 trendChart = new Chart(trendCanvas, buildTmTrendConfig(pts, ctrl, meta));
18312 trendCanvas.addEventListener('mouseleave', function() {{ trendCanvas.style.cursor = 'default'; }});
18313 ALL_CHARTS.push(trendChart);
18314
18315 // Populate submodule selector from unique project_labels
18316 var subSel = document.getElementById('tm-trend-sub');
18317 var subLabel = document.getElementById('tm-sub-label');
18318 if (subSel && data.length) {{
18319 var projects = [];
18320 data.forEach(function(d) {{ if (d.project_label && projects.indexOf(d.project_label) < 0) projects.push(d.project_label); }});
18321 if (projects.length > 1) {{
18322 var curVal = subSel.value;
18323 subSel.innerHTML = '<option value="">All (project total)</option>';
18324 projects.forEach(function(p) {{ subSel.innerHTML += '<option value="'+p.replace(/"/g,'"')+'"'+(p===curVal?' selected':'')+'>'+p+'</option>'; }});
18325 if (subLabel) subLabel.style.display = '';
18326 }} else {{
18327 if (subLabel) subLabel.style.display = 'none';
18328 }}
18329 }}
18330 }}
18331
18332 // ── Full View expand buttons ──────────────────────────────────────────────
18333 (function() {{
18334 var btn = document.getElementById('tests-expand-btn');
18335 if (!btn) return;
18336 btn.addEventListener('click', function() {{
18337 var D = currentLangTests;
18338 if (!D || !D.length) return;
18339 var top15 = D.slice(0, 15);
18340 var h = Math.max(320, top15.length * 36 + 80);
18341 var canvas = makeTmOverlay('Test Definitions by Language \u2014 Full View', top15.length + ' languages', h);
18342 if (!canvas) return;
18343 new Chart(canvas, {{
18344 type: 'bar',
18345 data: {{
18346 labels: top15.map(function(d){{ return d.lang; }}),
18347 datasets: [{{ label: 'Test Definitions', data: top15.map(function(d){{ return d.tests; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[i % PALETTE.length]; }}), borderRadius: 4 }}]
18348 }},
18349 options: {{
18350 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
18351 layout: {{ padding: {{ right: 72 }} }},
18352 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
18353 scales: {{
18354 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
18355 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
18356 }}
18357 }},
18358 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
18359 }});
18360 }});
18361 }})();
18362
18363 (function() {{
18364 var btn = document.getElementById('density-expand-btn');
18365 if (!btn) return;
18366 btn.addEventListener('click', function() {{
18367 var D = currentLangTests;
18368 if (!D || !D.length) return;
18369 var topD = D.slice().sort(function(a,b){{ return b.density - a.density; }}).slice(0, 15);
18370 var h = Math.max(320, topD.length * 36 + 80);
18371 var canvas = makeTmOverlay('Test Density (per 1,000 code lines) \u2014 Full View', topD.length + ' languages', h);
18372 if (!canvas) return;
18373 new Chart(canvas, {{
18374 type: 'bar',
18375 data: {{
18376 labels: topD.map(function(d){{ return d.lang; }}),
18377 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 }}]
18378 }},
18379 options: {{
18380 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
18381 layout: {{ padding: {{ right: 72 }} }},
18382 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + Number(ctx.parsed.x).toFixed(2) + ' / 1K'; }} }} }} }},
18383 scales: {{
18384 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return v.toFixed(1); }} }} }},
18385 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
18386 }}
18387 }},
18388 plugins: [makeDlPlugin(function(v){{ return v.toFixed(1); }}, 'end')]
18389 }});
18390 }});
18391 }})();
18392
18393 (function() {{
18394 var btn = document.getElementById('trend-expand-btn');
18395 if (!btn) return;
18396 btn.addEventListener('click', function() {{
18397 var pts = currentTrendPts;
18398 if (!pts || !pts.length) return;
18399 var ctrl = getTrendControls();
18400 var meta = TM_Y_META[ctrl.yKey] || TM_Y_META['test_count'];
18401 var title = meta.label + ' Trend \u2014 Full View';
18402 var canvas = makeTmOverlay(title, pts.length + ' scan' + (pts.length !== 1 ? 's' : ''), 440);
18403 if (!canvas) return;
18404 // Reuse the exact inline-chart config so Full View matches the default view
18405 // (straight segments + gradient-only interactivity), just larger.
18406 new Chart(canvas, buildTmTrendConfig(pts, ctrl, meta));
18407 }});
18408 }})();
18409
18410 (function() {{
18411 var btn = document.getElementById('assertions-expand-btn');
18412 if (!btn) return;
18413 btn.addEventListener('click', function() {{
18414 var D = currentLangTests;
18415 if (!D || !D.length) return;
18416 var top15 = D.filter(function(d){{ return d.assertions > 0; }}).slice(0, 15);
18417 if (!top15.length) return;
18418 var h = Math.max(320, top15.length * 36 + 80);
18419 var canvas = makeTmOverlay('Assertions by Language \u2014 Full View', top15.length + ' languages', h);
18420 if (!canvas) return;
18421 new Chart(canvas, {{
18422 type: 'bar',
18423 data: {{
18424 labels: top15.map(function(d){{ return d.lang; }}),
18425 datasets: [{{ label: 'Assertions', data: top15.map(function(d){{ return d.assertions; }}), backgroundColor: top15.map(function(_,i){{ return PALETTE[(i+2) % PALETTE.length]; }}), borderRadius: 4 }}]
18426 }},
18427 options: {{
18428 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
18429 layout: {{ padding: {{ right: 72 }} }},
18430 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
18431 scales: {{
18432 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
18433 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
18434 }}
18435 }},
18436 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
18437 }});
18438 }});
18439 }})();
18440
18441 (function() {{
18442 var btn = document.getElementById('suites-expand-btn');
18443 if (!btn) return;
18444 btn.addEventListener('click', function() {{
18445 var D = currentLangTests;
18446 if (!D || !D.length) return;
18447 var top15 = D.filter(function(d){{ return d.suites > 0; }}).slice(0, 15);
18448 if (!top15.length) return;
18449 var h = Math.max(320, top15.length * 36 + 80);
18450 var canvas = makeTmOverlay('Test Suites by Language \u2014 Full View', top15.length + ' languages', h);
18451 if (!canvas) return;
18452 new Chart(canvas, {{
18453 type: 'bar',
18454 data: {{
18455 labels: top15.map(function(d){{ return d.lang; }}),
18456 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 }}]
18457 }},
18458 options: {{
18459 responsive: true, maintainAspectRatio: false, indexAxis: 'y', onHover: tmFadeHover,
18460 layout: {{ padding: {{ right: 72 }} }},
18461 plugins: {{ legend: {{ display: false }}, tooltip: {{ callbacks: {{ label: function(ctx){{ return ' ' + fmtFull(ctx.parsed.x); }} }} }} }},
18462 scales: {{
18463 x: {{ grid: {{ color: clr() }}, ticks: {{ color: txtClr(), font:{{size:12}}, callback: function(v){{ return fmtFull(v); }} }} }},
18464 y: {{ grid: {{ color: 'transparent' }}, ticks: {{ color: txtClr(), font:{{size:12}} }} }}
18465 }}
18466 }},
18467 plugins: [makeDlPlugin(function(v){{ return fmtFull(v); }}, 'end')]
18468 }});
18469 }});
18470 }})();
18471
18472 // Wire trend control selectors — re-render without re-fetching
18473 (function() {{
18474 ['tm-trend-y','tm-trend-x','tm-trend-size','tm-trend-sub'].forEach(function(id) {{
18475 var el = document.getElementById(id);
18476 if (el) el.addEventListener('change', function() {{ renderTrend(); }});
18477 }});
18478 }})();
18479
18480 function loadTrend() {{
18481 var url = '/api/metrics/history?limit=100';
18482 if (currentRoot !== '__all__') url += '&root=' + encodeURIComponent(currentRoot);
18483 fetch(url).then(function(r){{ return r.json(); }}).then(function(data){{
18484 buildTrend(data);
18485 // Show Multi-Timeline button when >= 2 scans exist for the selected project.
18486 var btn = document.getElementById('multi-compare-trend-btn');
18487 if (btn) {{
18488 var ids = data.filter(function(d){{ return d.run_id; }}).map(function(d){{ return d.run_id; }});
18489 if (ids.length >= 2) {{
18490 btn.style.display = '';
18491 btn.onclick = function() {{
18492 // Reverse so oldest first (API returns newest first).
18493 var sorted = ids.slice().reverse();
18494 if (sorted.length === 2) {{
18495 window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
18496 }} else {{
18497 window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
18498 }}
18499 }};
18500 }} else {{
18501 btn.style.display = 'none';
18502 }}
18503 }}
18504 }}).catch(function(){{
18505 var trendEmpty = document.getElementById('trend-empty');
18506 if (trendEmpty) {{ trendEmpty.style.display = ''; trendEmpty.textContent = 'Failed to load trend data.'; }}
18507 }});
18508 }}
18509
18510 // Re-render charts on theme toggle
18511 document.getElementById('theme-toggle') && document.getElementById('theme-toggle').addEventListener('click', function() {{
18512 setTimeout(function() {{
18513 ALL_CHARTS.forEach(function(c) {{
18514 if (c && c.options && c.options.scales) {{
18515 Object.values(c.options.scales).forEach(function(ax) {{
18516 if (ax.grid) ax.grid.color = clr();
18517 if (ax.ticks) ax.ticks.color = txtClr();
18518 }});
18519 c.update();
18520 }}
18521 }});
18522 }}, 80);
18523 }});
18524
18525 // ── Export helpers (Excel / PNG / PDF) ───────────────────────────────────
18526 var TM_FONT = 'Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
18527 function tmExportMeta() {{
18528 var sel = document.getElementById('scope-sel');
18529 var proj = sel && sel.options[sel.selectedIndex] ? sel.options[sel.selectedIndex].text : 'All projects';
18530 if (!proj || proj === '__all__') proj = 'All projects';
18531 var now = new Date(); function p2(n) {{ return (n<10?'0':'')+n; }}
18532 var dstr = now.getFullYear()+'-'+p2(now.getMonth()+1)+'-'+p2(now.getDate());
18533 var tstr = p2(now.getHours())+':'+p2(now.getMinutes());
18534 var slug = dstr+'_'+p2(now.getHours())+p2(now.getMinutes());
18535 return {{ proj: proj, date: dstr, time: tstr, slug: slug, full: dstr+' '+tstr }};
18536 }}
18537
18538 function exportTmXLSX() {{
18539 var D = currentLangTests;
18540 if (!D || !D.length) {{ alert('No test data to export yet.'); return; }}
18541 var t = tmExportMeta();
18542 function s2b(s) {{ return new TextEncoder().encode(s); }}
18543 function xe(s) {{ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }}
18544 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; }}
18545 function crc32(d) {{
18546 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;}}}}
18547 var c=0xFFFFFFFF;for(var i=0;i<d.length;i++)c=crc32.t[(c^d[i])&0xFF]^(c>>>8);return(c^0xFFFFFFFF)>>>0;
18548 }}
18549 // Store all cells as strings so Excel left-aligns uniformly.
18550 function cs(addr, val, bold) {{
18551 return '<c r="'+addr+'" t="inlineStr"'+(bold?' s="1"':'')+"><is><t>"+xe(String(val))+'</t></is></c>';
18552 }}
18553 // Build an Excel Table XML definition for a given sheet range and columns.
18554 function makeTableXml(tblId, name, ref, cols) {{
18555 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
18556 x+='<table xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
18557 x+=' id="'+tblId+'" name="'+name+'" displayName="'+name+'" ref="'+ref+'" headerRowCount="1">';
18558 x+='<autoFilter ref="'+ref+'"/>';
18559 x+='<tableColumns count="'+cols.length+'">';
18560 cols.forEach(function(col,i){{x+='<tableColumn id="'+(i+1)+'" name="'+xe(col)+'"/>';}});
18561 x+='</tableColumns>';
18562 x+='<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>';
18563 return x+'</table>';
18564 }}
18565 // Worksheet XML with optional Excel Table part reference.
18566 function buildSheet(hdr, rows, totRow, colWidths, tblRid) {{
18567 var ns='xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"';
18568 if(tblRid)ns+=' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';
18569 var cw='<cols>';colWidths.forEach(function(w,i){{cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';}});cw+='</cols>';
18570 var x='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet '+ns+'>'+cw+'<sheetData>';
18571 x+='<row r="1">';hdr.forEach(function(h,ci){{x+=cs(col2l(ci+1)+'1',h,true);}});x+='</row>';
18572 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>';}});
18573 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>';}}
18574 x+='</sheetData>';
18575 if(tblRid)x+='<tableParts count="1"><tablePart r:id="'+tblRid+'"/></tableParts>';
18576 return x+'</worksheet>';
18577 }}
18578
18579 var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
18580 var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
18581 var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
18582 var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
18583 var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
18584 var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
18585
18586 // ── Build the worksheet list (test metrics + optional LCOV coverage) ──
18587 // Each entry: {{name, tbl (Excel table name), hdr, rows, tot, cols}}.
18588 var sheets=[];
18589
18590 // Sheet: Summary
18591 var sumHdr=['Metric','Value'];
18592 var sumRows=[
18593 ['Project / Scope', t.proj],
18594 ['Export Date', t.full],
18595 ['Test Functions', Number(totTests).toLocaleString()],
18596 ['Assertions', Number(totAssert).toLocaleString()],
18597 ['Test Suites', Number(totSuites).toLocaleString()],
18598 ['Languages with Tests', String(D.length)],
18599 ['Total Code Lines', Number(totCode).toLocaleString()],
18600 ['Average Density (per 1K)', String(avgDensity)],
18601 ];
18602 sheets.push({{name:'Summary',tbl:'Summary',hdr:sumHdr,rows:sumRows,tot:null,cols:[28,22]}});
18603
18604 // Sheet: Language Breakdown (TOTAL row sits just below the table range)
18605 var langHdr=['Language','Test Functions','Assertions','Test Suites','Code Lines','Files','Density (per 1K)'];
18606 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)];}});
18607 var totRow=['TOTAL',Number(totTests).toLocaleString(),Number(totAssert).toLocaleString(),Number(totSuites).toLocaleString(),Number(totCode).toLocaleString(),Number(totFiles).toLocaleString(),String(avgDensity)];
18608 sheets.push({{name:'Language Breakdown',tbl:'LangBreakdown',hdr:langHdr,rows:langRows,tot:totRow,cols:[22,15,15,15,15,12,15]}});
18609
18610 // Sheets: LCOV Coverage Summary (appended only when the current scope has coverage)
18611 var covDs=(typeof getDataset==='function')?getDataset():null;
18612 if(covDs&&covDs.has_coverage){{
18613 var covT=covDs.totals||{{}};
18614 var covSumHdr=['Metric','Value'];
18615 var covSumRows=[
18616 ['Line Coverage', (covT.cov_line||'0')+'%'],
18617 ['Function Coverage', (covT.cov_fn||'0')+'%'],
18618 ['Branch Coverage', (covT.cov_branch||'0')+'%'],
18619 ];
18620 if(covDs.cov_tiers){{
18621 covSumRows.push(['Files High (≥80%)', String(covDs.cov_tiers.high||0)]);
18622 covSumRows.push(['Files Moderate (50-79%)', String(covDs.cov_tiers.mid||0)]);
18623 covSumRows.push(['Files Low (<50%)', String(covDs.cov_tiers.low||0)]);
18624 }}
18625 sheets.push({{name:'Coverage Summary',tbl:'CoverageSummary',hdr:covSumHdr,rows:covSumRows,tot:null,cols:[26,14]}});
18626
18627 if(covDs.cov&&covDs.cov.length){{
18628 var covLangHdr=['Language','Line Coverage %'];
18629 var covLangRows=covDs.cov.map(function(c){{return[c.lang,Number(c.pct).toFixed(1)];}});
18630 sheets.push({{name:'Coverage by Language',tbl:'CoverageByLang',hdr:covLangHdr,rows:covLangRows,tot:null,cols:[24,18]}});
18631 }}
18632 if(covFileData&&covFileData.length){{
18633 var covFileHdr=['File','Language','Line %','Lines Hit','Lines Found','Function %','Fns Hit','Fns Found'];
18634 var covFileRows=covFileData.map(function(f){{
18635 var noFn=f.fn_pct<0;
18636 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)];
18637 }});
18638 sheets.push({{name:'Coverage by File',tbl:'CoverageByFile',hdr:covFileHdr,rows:covFileRows,tot:null,cols:[40,14,10,10,12,12,10,10]}});
18639 }}
18640 }}
18641
18642 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>';
18643 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>';
18644
18645 // Assemble per-sheet parts, content-type overrides, and workbook relationships.
18646 var files=[];
18647 var ctOverrides='', wbSheetTags='', wbRelTags='';
18648 sheets.forEach(function(sh,i){{
18649 var n=i+1;
18650 var lastCol=col2l(sh.hdr.length);
18651 var ref='A1:'+lastCol+(sh.rows.length+1);
18652 var sheetXml=buildSheet(sh.hdr,sh.rows,sh.tot,sh.cols,'rId1');
18653 var tblXml=makeTableXml(n,sh.tbl,ref,sh.hdr);
18654 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>';
18655 files.push({{name:'xl/worksheets/sheet'+n+'.xml',data:s2b(sheetXml)}});
18656 files.push({{name:'xl/worksheets/_rels/sheet'+n+'.xml.rels',data:s2b(shRels)}});
18657 files.push({{name:'xl/tables/table'+n+'.xml',data:s2b(tblXml)}});
18658 ctOverrides+='<Override PartName="/xl/worksheets/sheet'+n+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';
18659 ctOverrides+='<Override PartName="/xl/tables/table'+n+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';
18660 wbSheetTags+='<sheet name="'+xe(sh.name)+'" sheetId="'+n+'" r:id="rId'+n+'"/>';
18661 wbRelTags+='<Relationship Id="rId'+n+'" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet'+n+'.xml"/>';
18662 }});
18663 var styleRid='rId'+(sheets.length+1);
18664 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>';
18665 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>';
18666 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>';
18667 files.unshift(
18668 {{name:'[Content_Types].xml',data:s2b(ct)}},
18669 {{name:'_rels/.rels',data:s2b(dotrels)}},
18670 {{name:'xl/workbook.xml',data:s2b(wbx)}},
18671 {{name:'xl/_rels/workbook.xml.rels',data:s2b(wbr)}},
18672 {{name:'xl/styles.xml',data:s2b(styl)}}
18673 );
18674 var parts=[],offsets=[],total=0;
18675 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;}});
18676 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;}});
18677 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));
18678 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;}});
18679 var proj2=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
18680 var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj2+'-'+t.slug+'.xlsx';
18681 a.href=URL.createObjectURL(new Blob([out.buffer],{{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}}));
18682 a.click();setTimeout(function(){{URL.revokeObjectURL(a.href);}},1000);
18683 }}
18684
18685 function exportTmPNG() {{
18686 // Map canvas IDs to display titles
18687 var CHART_TITLES = {{
18688 'canvas-trend': 'TEST COUNT TREND',
18689 'canvas-tests': 'TEST DEFINITIONS BY LANGUAGE',
18690 'canvas-density': 'TEST DENSITY (per 1,000 code lines)',
18691 'canvas-assertions': 'ASSERTIONS BY LANGUAGE',
18692 'canvas-suites': 'TEST SUITES BY LANGUAGE',
18693 'canvas-files': 'TEST FILES BREAKDOWN',
18694 'canvas-composition': 'TEST COMPOSITION',
18695 'canvas-cov': 'LINE COVERAGE % BY LANGUAGE',
18696 'canvas-cov-tiers': 'COVERAGE TIER DISTRIBUTION'
18697 }};
18698 // Coverage canvases are only appended when the LCOV panel is visible (has data).
18699 var covPanelEl=document.getElementById('cov-panel');
18700 var covShown=covPanelEl&&covPanelEl.style.display!=='none';
18701 var ids=['canvas-trend','canvas-tests','canvas-density','canvas-assertions','canvas-suites','canvas-files','canvas-composition'];
18702 if(covShown){{ids.push('canvas-cov','canvas-cov-tiers');}}
18703 // Include only charts that actually rendered data. A "no data" chart has its
18704 // canvas wrap hidden (offsetParent===null) with a placeholder shown instead —
18705 // skip those so the image has no empty gaps (e.g. Assertions/Suites at 0).
18706 function chartHasData(c){{return c&&c.width>0&&c.offsetParent!==null;}}
18707 var canvases=ids.map(function(id){{return document.getElementById(id);}}).filter(chartHasData);
18708 if(!canvases.length){{alert('No charts rendered yet. Run a scan first.');return;}}
18709 var t=tmExportMeta();
18710 var COLW=760, GAP=16, HEADER_H=102, FOOTER_H=40, ROW_PAD=18, TITLE_H=26;
18711 var trendCanvas=document.getElementById('canvas-trend');
18712 var hasTrend=chartHasData(trendCanvas);
18713 var gridCanvases=canvases.filter(function(c){{return c.id!=='canvas-trend';}});
18714 var TOTAL_W=COLW*2+GAP;
18715 var TREND_H=hasTrend?Math.round(TOTAL_W*(trendCanvas.height/Math.max(trendCanvas.width,1))):0;
18716 TREND_H=Math.min(Math.max(200,TREND_H),340);
18717 // Per-row chart heights (2-col grid)
18718 var gridRows=Math.ceil(gridCanvases.length/2);
18719 var rowHeights=[];
18720 for(var ri=0;ri<gridRows;ri++){{
18721 var rh=240;
18722 for(var ci=0;ci<2;ci++){{
18723 var cv=gridCanvases[ri*2+ci];
18724 if(cv&&cv.width>0){{
18725 var nat=Math.round(COLW*cv.height/Math.max(cv.width,1));
18726 rh=Math.max(rh,Math.min(420,nat));
18727 }}
18728 }}
18729 rowHeights.push(rh);
18730 }}
18731 var gridH=rowHeights.reduce(function(a,b){{return a+TITLE_H+b+ROW_PAD;}},0);
18732 var trendSection=hasTrend?TITLE_H+TREND_H+ROW_PAD:0;
18733 var TOTAL_H=HEADER_H+trendSection+gridH+FOOTER_H;
18734 var out=document.createElement('canvas');out.width=TOTAL_W;out.height=TOTAL_H;
18735 var ctx=out.getContext('2d');
18736 var cs2=getComputedStyle(document.body);
18737 var bg=cs2.getPropertyValue('--bg').trim()||'#f5efe8';
18738 var oxide=cs2.getPropertyValue('--oxide').trim()||'#C45C10';
18739 var muted=cs2.getPropertyValue('--muted').trim()||'#7b675b';
18740
18741 // Background
18742 ctx.fillStyle=bg;ctx.fillRect(0,0,TOTAL_W,TOTAL_H);
18743
18744 // Orange header block
18745 ctx.fillStyle=oxide;ctx.fillRect(0,0,TOTAL_W,HEADER_H-8);
18746 ctx.fillStyle='#fff';ctx.font='800 24px '+TM_FONT;ctx.textBaseline='alphabetic';ctx.textAlign='left';
18747 ctx.fillText('Test Metrics — '+t.proj,22,42);
18748 ctx.fillStyle='rgba(255,255,255,0.82)';ctx.font='600 13px '+TM_FONT;
18749 ctx.fillText('oxide-sloc v{version} · Generated '+t.full,22,70);
18750 ctx.fillStyle=bg;ctx.fillRect(0,HEADER_H-8,TOTAL_W,TOTAL_H-(HEADER_H-8));
18751
18752 // Helper: draw a section title label
18753 function drawTitle(label, x, y, w) {{
18754 ctx.save();
18755 ctx.fillStyle=oxide;
18756 ctx.font='700 11px '+TM_FONT;
18757 ctx.textBaseline='middle';
18758 ctx.textAlign='left';
18759 ctx.letterSpacing='0.07em';
18760 ctx.fillText(label, x+2, y+TITLE_H/2);
18761 // Underline
18762 ctx.strokeStyle=oxide;ctx.globalAlpha=0.35;ctx.lineWidth=1;
18763 ctx.beginPath();ctx.moveTo(x,y+TITLE_H-2);ctx.lineTo(x+w,y+TITLE_H-2);ctx.stroke();
18764 ctx.globalAlpha=1;
18765 ctx.restore();
18766 }}
18767
18768 var yOff=HEADER_H;
18769
18770 // Trend chart (full width)
18771 if(hasTrend){{
18772 drawTitle(CHART_TITLES['canvas-trend']||'TEST COUNT TREND', 4, yOff, TOTAL_W-8);
18773 yOff+=TITLE_H;
18774 var surf=document.createElement('canvas');surf.width=TOTAL_W;surf.height=TREND_H;
18775 var sc=surf.getContext('2d');sc.fillStyle=bg;sc.fillRect(0,0,TOTAL_W,TREND_H);
18776 sc.drawImage(trendCanvas,0,0,TOTAL_W,TREND_H);
18777 ctx.drawImage(surf,0,yOff);
18778 yOff+=TREND_H+ROW_PAD;
18779 }}
18780
18781 // Grid charts (2-col), each cell gets title + chart
18782 for(var gi=0;gi<gridRows;gi++){{
18783 var rh2=rowHeights[gi];
18784 // Draw row titles and charts
18785 for(var gci=0;gci<2;gci++){{
18786 var idx2=gi*2+gci;
18787 if(idx2>=gridCanvases.length)continue;
18788 var gcv=gridCanvases[idx2];
18789 var gx=gci*(COLW+GAP);
18790 drawTitle(CHART_TITLES[gcv.id]||gcv.id.replace('canvas-','').toUpperCase(), gx+4, yOff, COLW-8);
18791 }}
18792 yOff+=TITLE_H;
18793 for(var gci2=0;gci2<2;gci2++){{
18794 var idx3=gi*2+gci2;
18795 if(idx3>=gridCanvases.length)continue;
18796 var gcv2=gridCanvases[idx3];
18797 var gx2=gci2*(COLW+GAP);
18798 var natW=gcv2.width,natH=gcv2.height;
18799 var scale=Math.min(COLW/Math.max(natW,1),rh2/Math.max(natH,1));
18800 var dw=Math.round(natW*scale),dh=Math.round(natH*scale);
18801 var surf2=document.createElement('canvas');surf2.width=COLW;surf2.height=rh2;
18802 var sc2=surf2.getContext('2d');sc2.fillStyle=bg;sc2.fillRect(0,0,COLW,rh2);
18803 sc2.drawImage(gcv2,Math.round((COLW-dw)/2),Math.round((rh2-dh)/2),dw,dh);
18804 ctx.drawImage(surf2,gx2,yOff);
18805 }}
18806 yOff+=rh2+ROW_PAD;
18807 }}
18808
18809 // Dark footer
18810 ctx.fillStyle='#43342d';ctx.fillRect(0,TOTAL_H-FOOTER_H,TOTAL_W,FOOTER_H);
18811 ctx.fillStyle='rgba(255,255,255,0.72)';ctx.font='600 11px '+TM_FONT;ctx.textAlign='center';
18812 ctx.fillText('© 2026 OxideSLOC · oxide-sloc v{version} · AGPL-3.0-or-later',TOTAL_W/2,TOTAL_H-FOOTER_H+24);
18813
18814 var proj3=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
18815 var a=document.createElement('a');a.download='oxide-sloc-test-metrics-'+proj3+'-'+t.slug+'.png';a.href=out.toDataURL('image/png');a.click();
18816 }}
18817
18818 function exportTmPDF(ev) {{
18819 var D=currentLangTests;
18820 var t=tmExportMeta();
18821 var strips=document.querySelectorAll('.summary-strip');
18822 var statsHtml='';strips.forEach(function(s){{statsHtml+=s.outerHTML;}});
18823 var totTests=D.reduce(function(a,d){{return a+d.tests;}},0);
18824 var totAssert=D.reduce(function(a,d){{return a+(d.assertions||0);}},0);
18825 var totSuites=D.reduce(function(a,d){{return a+(d.suites||0);}},0);
18826 var totCode=D.reduce(function(a,d){{return a+d.code;}},0);
18827 var totFiles=D.reduce(function(a,d){{return a+d.files;}},0);
18828 var avgDensity=totCode>0?(totTests/totCode*1000).toFixed(2):'0.00';
18829 var rows='';
18830 (D||[]).forEach(function(d){{
18831 rows+='<tr><td><strong>'+d.lang+'</strong></td>'
18832 +'<td class="n">'+Number(d.tests).toLocaleString()+'</td>'
18833 +'<td class="n">'+Number(d.assertions||0).toLocaleString()+'</td>'
18834 +'<td class="n">'+Number(d.suites||0).toLocaleString()+'</td>'
18835 +'<td class="n">'+Number(d.code).toLocaleString()+'</td>'
18836 +'<td class="n">'+Number(d.files).toLocaleString()+'</td>'
18837 +'<td class="n">'+Number(d.density).toFixed(2)+'</td></tr>';
18838 }});
18839 var totRow='<tr class="tot-row"><td><strong>TOTAL</strong></td>'
18840 +'<td class="n"><strong>'+Number(totTests).toLocaleString()+'</strong></td>'
18841 +'<td class="n"><strong>'+Number(totAssert).toLocaleString()+'</strong></td>'
18842 +'<td class="n"><strong>'+Number(totSuites).toLocaleString()+'</strong></td>'
18843 +'<td class="n"><strong>'+Number(totCode).toLocaleString()+'</strong></td>'
18844 +'<td class="n"><strong>'+Number(totFiles).toLocaleString()+'</strong></td>'
18845 +'<td class="n"><strong>'+avgDensity+'</strong></td></tr>';
18846 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>';
18847 var css='<style>*{{box-sizing:border-box;margin:0;padding:0;}}'
18848 +'html,body{{height:100%;margin:0;}}'
18849 +'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;}}'
18850 +'.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;}}'
18851 +'.rep-header h1{{font-size:22px;font-weight:900;margin:0;color:#fff;}}'
18852 +'.rep-header .sub{{font-size:12px;margin:5px 0 0;color:rgba(255,255,255,0.85);}}'
18853 +'.rep-brand{{font-size:14px;font-weight:800;color:#fff;text-align:right;}}'
18854 +'.rep-brand small{{display:block;font-weight:500;font-size:11px;opacity:.85;margin-top:2px;}}'
18855 +'.rep-body{{padding:20px 32px;flex:1;}}'
18856 +'.summary-strip{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 12px;}}'
18857 +'.stat-chip{{border:1px solid #e6d0bf;border-radius:10px;padding:10px 12px;position:relative;}}'
18858 +'.stat-chip-tip,.stat-chip-exact{{display:none!important;}}'
18859 +'.stat-chip-val{{font-size:17px;font-weight:900;color:#C45C10;}}'
18860 +'.stat-chip-label{{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#7b675b;margin-top:3px;}}'
18861 +'.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;}}'
18862 +'table{{border-collapse:collapse;width:100%;font-size:11px;margin-top:4px;}}'
18863 +'th,td{{border:1px solid #e6d0bf;padding:5px 8px;text-align:left;white-space:nowrap;}}'
18864 +'th{{background:#f5efe8;font-weight:800;font-size:10px;}}'
18865 +'.n{{text-align:right;}}'
18866 +'.tot-row td{{background:#f0e6dc;border-top:2px solid #C45C10;}}'
18867 +'.cov-strip{{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:4px 0 8px;}}'
18868 +'.cov-card{{border:1px solid #e6d0bf;border-radius:10px;padding:10px 12px;}}'
18869 +'.cov-k{{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#7b675b;}}'
18870 +'.cov-v{{font-size:18px;font-weight:900;color:#2a6846;margin-top:3px;}}'
18871 +'.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;}}'
18872 +'</style>';
18873 // LCOV Coverage Summary section — only rendered when the current scope has coverage.
18874 var covDs=(typeof getDataset==='function')?getDataset():null;
18875 var covHtml='';
18876 if(covDs&&covDs.has_coverage){{
18877 var covT=covDs.totals||{{}};
18878 covHtml+='<div class="section-hdr">LCOV Coverage Summary</div>'
18879 +'<div class="cov-strip">'
18880 +'<div class="cov-card"><div class="cov-k">Line Coverage</div><div class="cov-v">'+(covT.cov_line||'0')+'%</div></div>'
18881 +'<div class="cov-card"><div class="cov-k">Function Coverage</div><div class="cov-v">'+(covT.cov_fn||'0')+'%</div></div>'
18882 +'<div class="cov-card"><div class="cov-k">Branch Coverage</div><div class="cov-v">'+(covT.cov_branch||'0')+'%</div></div>'
18883 +'</div>';
18884 if(covFileData&&covFileData.length){{
18885 var cfrows='';
18886 covFileData.forEach(function(f){{
18887 var noFn=f.fn_pct<0;
18888 cfrows+='<tr><td>'+f.rel+'</td><td>'+f.lang+'</td>'
18889 +'<td class="n">'+Number(f.line_pct).toFixed(1)+'%</td>'
18890 +'<td class="n">'+f.lhit+' / '+f.lfound+'</td>'
18891 +'<td class="n">'+(noFn?'—':Number(f.fn_pct).toFixed(1)+'%')+'</td>'
18892 +'<td class="n">'+(noFn?'—':f.fhit+' / '+f.ffound)+'</td></tr>';
18893 }});
18894 covHtml+='<div class="section-hdr">Coverage File Detail</div>'
18895 +'<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>';
18896 }}
18897 }}
18898 var doc='<!doctype html><html><head><meta charset="utf-8"><title>OxideSLOC Test Metrics</title>'+css+'</head><body>'
18899 +'<div class="rep-header"><div><h1>Test Metrics Report</h1><p class="sub">Scope: '+t.proj+' · Generated: '+t.full+'</p></div>'
18900 +'<div class="rep-brand">OxideSLOC<small>oxide-sloc v{version}</small></div></div>'
18901 +'<div class="rep-body">'+statsHtml
18902 +'<div class="section-hdr">Language Breakdown</div>'
18903 +tableHtml+covHtml+'</div>'
18904 +'<div class="rep-footer">© 2026 OxideSLOC · oxide-sloc v{version} · local code metrics workbench · AGPL-3.0-or-later · Generated '+t.full+'</div>'
18905 +'</body></html>';
18906 var proj4=t.proj.replace(/[^a-zA-Z0-9_-]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').substring(0,30)||'all';
18907 var pdfBtn=(ev&&ev.currentTarget)||document.getElementById('tm-export-pdf-btn');
18908 window.slocExportPdf({{html:doc,filename:'oxide-sloc-test-metrics-'+proj4+'-'+t.slug+'.pdf',button:pdfBtn}});
18909 }}
18910
18911 (function() {{
18912 // Page-level export controls (Scope toolbar). Every button exports the ENTIRE
18913 // Test Metrics page — test metrics + the LCOV Coverage Summary — for the scope.
18914 var xBtn=document.getElementById('tm-export-xlsx-btn');
18915 var pngBtn=document.getElementById('tm-export-png-btn');
18916 var pdfBtn=document.getElementById('tm-export-pdf-btn');
18917 if(xBtn)xBtn.addEventListener('click',exportTmXLSX);
18918 if(pngBtn)pngBtn.addEventListener('click',exportTmPNG);
18919 if(pdfBtn)pdfBtn.addEventListener('click',exportTmPDF);
18920 }})();
18921
18922 applyScope();
18923 }})();
18924 </script>
18925 <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>
18926 {toast_assets}
18927</body>
18928</html>"#,
18929 );
18930 (
18931 [(axum::http::header::CACHE_CONTROL, "no-store")],
18932 Html(html),
18933 )
18934 .into_response()
18935}
18936
18937#[derive(Deserialize)]
18944struct EmbedQuery {
18945 run_id: Option<String>,
18946 theme: Option<String>,
18947}
18948
18949async fn embed_handler(
18950 State(state): State<AppState>,
18951 axum::extract::Extension(CspNonce(csp_nonce)): axum::extract::Extension<CspNonce>,
18952 Query(query): Query<EmbedQuery>,
18953) -> Response {
18954 let entry = {
18955 let reg = state.registry.lock().await;
18956 query.run_id.as_ref().map_or_else(
18957 || reg.entries.first().cloned(),
18958 |id| reg.find_by_run_id(id).cloned(),
18959 )
18960 };
18961
18962 let Some(entry) = entry else {
18963 return Html(
18964 "<p style='font-family:sans-serif;padding:12px'>No scan data available.</p>"
18965 .to_string(),
18966 )
18967 .into_response();
18968 };
18969
18970 let dark = query.theme.as_deref() == Some("dark");
18971 let languages: Vec<(String, u64, u64)> = entry
18972 .json_path
18973 .as_ref()
18974 .and_then(|p| read_json(p).ok())
18975 .map(|run| {
18976 run.totals_by_language
18977 .iter()
18978 .map(|l| (l.language.display_name().to_string(), l.files, l.code_lines))
18979 .collect()
18980 })
18981 .unwrap_or_default();
18982
18983 Html(render_embed_widget(&entry, &languages, dark, &csp_nonce)).into_response()
18984}
18985
18986fn render_embed_widget(
18987 entry: &RegistryEntry,
18988 languages: &[(String, u64, u64)],
18989 dark: bool,
18990 csp_nonce: &str,
18991) -> String {
18992 let s = &entry.summary;
18993 let total = s.code_lines + s.comment_lines + s.blank_lines;
18994 let code_pct = s
18995 .code_lines
18996 .checked_mul(100)
18997 .and_then(|n| n.checked_div(total))
18998 .unwrap_or(0);
18999
19000 let (bg, fg, surface, muted, border) = if dark {
19001 ("#1b1511", "#f5ece6", "#2d221d", "#c7b7aa", "#524238")
19002 } else {
19003 ("#f8f5f2", "#43342d", "#ffffff", "#7b675b", "#e6d0bf")
19004 };
19005
19006 let mut lang_rows = String::new();
19007 for (name, files, code) in languages {
19008 write!(
19009 lang_rows,
19010 "<tr><td>{}</td><td class='n'>{}</td><td class='n'>{}</td></tr>",
19011 escape_html(name),
19012 format_number(*files),
19013 format_number(*code),
19014 )
19015 .ok();
19016 }
19017
19018 let lang_table = if lang_rows.is_empty() {
19019 String::new()
19020 } else {
19021 format!(
19022 "<table class='lt'><thead><tr><th>Language</th><th>Files</th><th>Code</th></tr></thead><tbody>{lang_rows}</tbody></table>"
19023 )
19024 };
19025
19026 let run_short = &entry.run_id[..entry.run_id.len().min(8)];
19027 let timestamp = entry.timestamp_utc.format("%Y-%m-%d %H:%M UTC");
19028 let project_esc = escape_html(&entry.project_label);
19029 let code_lines = format_number(s.code_lines);
19030 let comment_lines = format_number(s.comment_lines);
19031 let files = format_number(s.files_analyzed);
19032 let code_raw = s.code_lines;
19033 let comment_raw = s.comment_lines;
19034 let blank_raw = s.blank_lines;
19035
19036 format!(
19037 r#"<!doctype html>
19038<html lang="en">
19039<head>
19040 <meta charset="utf-8">
19041 <meta name="viewport" content="width=device-width,initial-scale=1">
19042 <title>OxideSLOC — {project_esc}</title>
19043 <script src="/static/chart.js"></script>
19044 <style nonce="{csp_nonce}">
19045 *{{box-sizing:border-box;margin:0;padding:0}}
19046 body{{background:{bg};color:{fg};font-family:system-ui,sans-serif;font-size:13px;padding:12px}}
19047 h2{{font-size:15px;font-weight:700;margin-bottom:2px}}
19048 .sub{{color:{muted};font-size:11px;margin-bottom:10px}}
19049 .cards{{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}}
19050 .card{{background:{surface};border:1px solid {border};border-radius:6px;padding:8px 12px;min-width:90px}}
19051 .card .v{{font-size:18px;font-weight:700}}
19052 .card .l{{color:{muted};font-size:10px;margin-top:2px}}
19053 .row{{display:flex;gap:12px;align-items:flex-start}}
19054 .pie{{width:120px;height:120px;flex-shrink:0}}
19055 .lt{{border-collapse:collapse;width:100%;flex:1}}
19056 .lt th,.lt td{{padding:3px 6px;border-bottom:1px solid {border}}}
19057 .lt th{{color:{muted};font-weight:600;text-align:left;font-size:11px}}
19058 .n{{text-align:right}}
19059 .footer{{margin-top:10px;color:{muted};font-size:10px}}
19060 </style>
19061</head>
19062<body>
19063 <h2>{project_esc}</h2>
19064 <div class="sub">{timestamp} · run {run_short}</div>
19065 <div class="cards">
19066 <div class="card"><div class="v">{code_lines}</div><div class="l">code lines</div></div>
19067 <div class="card"><div class="v">{files}</div><div class="l">files</div></div>
19068 <div class="card"><div class="v">{comment_lines}</div><div class="l">comments</div></div>
19069 <div class="card"><div class="v">{code_pct}%</div><div class="l">code ratio</div></div>
19070 </div>
19071 <div class="row">
19072 <canvas class="pie" id="c"></canvas>
19073 {lang_table}
19074 </div>
19075 <div class="footer">oxide-sloc</div>
19076 <script nonce="{csp_nonce}">
19077 new Chart(document.getElementById('c'),{{
19078 type:'doughnut',
19079 data:{{
19080 labels:['Code','Comments','Blank'],
19081 datasets:[{{
19082 data:[{code_raw},{comment_raw},{blank_raw}],
19083 backgroundColor:['#4a78ee','#b35428','#aaa'],
19084 borderWidth:0
19085 }}]
19086 }},
19087 options:{{plugins:{{legend:{{display:false}}}},cutout:'60%',animation:false}}
19088 }});
19089 </script>
19090</body>
19091</html>"#
19092 )
19093}
19094
19095fn output_dir_lock(dir: &Path) -> Arc<std::sync::Mutex<()>> {
19100 static LOCKS: OnceLock<std::sync::Mutex<HashMap<PathBuf, Arc<std::sync::Mutex<()>>>>> =
19101 OnceLock::new();
19102 let map = LOCKS.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
19103 let mut guard = map
19104 .lock()
19105 .unwrap_or_else(std::sync::PoisonError::into_inner);
19106 guard
19107 .entry(dir.to_path_buf())
19108 .or_insert_with(|| Arc::new(std::sync::Mutex::new(())))
19109 .clone()
19110}
19111
19112#[allow(clippy::too_many_lines)]
19113fn persist_run_artifacts(
19114 run: &sloc_core::AnalysisRun,
19115 report_html: &str,
19116 run_dir: &Path,
19117 report_title: &str,
19118 file_stem: &str,
19119 result_context: RunResultContext,
19120) -> Result<(RunArtifacts, PendingPdf)> {
19121 let dir_lock = output_dir_lock(run_dir);
19124 let _dir_guard = dir_lock
19125 .lock()
19126 .unwrap_or_else(std::sync::PoisonError::into_inner);
19127
19128 let html_dir = run_dir.join("html");
19130 let pdf_dir = run_dir.join("pdf");
19131 let excel_dir = run_dir.join("excel");
19132 let json_dir = run_dir.join("json");
19133 let submodules_dir = run_dir.join("submodules");
19134 for dir in &[
19135 run_dir,
19136 &html_dir,
19137 &pdf_dir,
19138 &excel_dir,
19139 &json_dir,
19140 &submodules_dir,
19141 ] {
19142 fs::create_dir_all(dir)
19143 .with_context(|| format!("failed to create directory {}", dir.display()))?;
19144 }
19145
19146 let html_path = {
19148 let path = sloc_core::reject_traversal(&html_dir.join(format!("report_{file_stem}.html")))?;
19149 fs::write(&path, report_html)
19150 .with_context(|| format!("failed to write HTML report to {}", path.display()))?;
19151 Some(path)
19152 };
19153
19154 let json_path = {
19156 let path = sloc_core::reject_traversal(&json_dir.join(format!("result_{file_stem}.json")))?;
19157 let json = serde_json::to_string_pretty(run)
19158 .context("failed to serialize analysis run to JSON")?;
19159 fs::write(&path, json)
19160 .with_context(|| format!("failed to write JSON result to {}", path.display()))?;
19161 Some(path)
19162 };
19163
19164 let (pdf_path, pending_pdf) = {
19166 let pdf_dest = pdf_dir.join(format!("report_{file_stem}.pdf"));
19167 match write_pdf_from_run(run, &pdf_dest) {
19168 Ok(()) => {
19169 eprintln!(
19170 "[oxide-sloc][pdf] native PDF written to {}",
19171 pdf_dest.display()
19172 );
19173 (Some(pdf_dest), None)
19174 }
19175 Err(native_err) => {
19176 eprintln!(
19177 "[oxide-sloc][pdf] native PDF failed ({native_err:#}), scheduling HTML->browser fallback"
19178 );
19179 let source_html_path = html_path
19180 .as_ref()
19181 .expect("html_path always Some here")
19182 .clone();
19183 let pending = Some((source_html_path, pdf_dest.clone(), false));
19184 (Some(pdf_dest), pending)
19185 }
19186 }
19187 };
19188
19189 let csv_path = {
19191 let path = excel_dir.join(format!("report_{file_stem}.csv"));
19192 match sloc_report::write_csv(run, &path) {
19193 Err(e) => {
19194 eprintln!("[oxide-sloc] CSV write failed (non-fatal): {e:#}");
19195 None
19196 }
19197 _ => Some(path),
19198 }
19199 };
19200
19201 let xlsx_path = {
19202 let path = excel_dir.join(format!("report_{file_stem}.xlsx"));
19203 match sloc_report::write_xlsx(run, &path) {
19204 Err(e) => {
19205 eprintln!("[oxide-sloc] XLSX write failed (non-fatal): {e:#}");
19206 None
19207 }
19208 _ => Some(path),
19209 }
19210 };
19211
19212 let scan_config_path = Some(json_dir.join(format!("scan-config_{file_stem}.json")));
19214
19215 if run.effective_configuration.discovery.submodule_breakdown {
19217 let run_id = &run.tool.run_id;
19218 for s in &run.submodule_summaries {
19219 build_submodule_row(s, run, run_id, run_dir);
19220 }
19221 }
19222
19223 generate_offline_index(
19225 run,
19226 run_dir,
19227 file_stem,
19228 html_path.as_deref(),
19229 pdf_path.as_deref(),
19230 json_path.as_deref(),
19231 scan_config_path.as_deref(),
19232 &result_context,
19233 );
19234
19235 Ok((
19236 RunArtifacts {
19237 output_dir: run_dir.to_path_buf(),
19238 html_path,
19239 pdf_path,
19240 json_path,
19241 csv_path,
19242 xlsx_path,
19243 scan_config_path,
19244 report_title: report_title.to_string(),
19245 result_context,
19246 },
19247 pending_pdf,
19248 ))
19249}
19250
19251pub fn bundle_run(
19275 run: &AnalysisRun,
19276 out_root: &Path,
19277 run_id: &str,
19278 label: Option<&str>,
19279) -> Result<PathBuf> {
19280 let project_label = match label.map(str::trim).filter(|s| !s.is_empty()) {
19283 Some(explicit) => sanitize_project_label(explicit),
19284 None => {
19285 let fallback = run.input_roots.first().map_or("", String::as_str);
19286 derive_project_label(None, None, fallback)
19287 }
19288 };
19289
19290 let run_dir = out_root.join(derive_run_dir_name(
19291 &project_label,
19292 run.git_branch.as_deref(),
19293 run_id,
19294 ));
19295 let file_stem = derive_file_stem(&project_label, run.git_commit_short.as_deref());
19296
19297 let report_html = render_html(run).context("failed to render HTML report for bundle output")?;
19298
19299 let project_path = run.input_roots.first().cloned().unwrap_or_default();
19300 let result_context = RunResultContext {
19301 prev_entry: None,
19302 prev_scan_count: 0,
19303 project_path: project_path.clone(),
19304 cocomo_mode: "organic".to_string(),
19305 complexity_alert: 0,
19306 exclude_duplicates: false,
19307 };
19308
19309 let (artifacts, _pending_pdf) = persist_run_artifacts(
19310 run,
19311 &report_html,
19312 &run_dir,
19313 &run.effective_configuration.reporting.report_title,
19314 &file_stem,
19315 result_context,
19316 )?;
19317
19318 if let Some(ref cfg_path) = artifacts.scan_config_path {
19320 save_scan_config_json(
19321 cfg_path,
19322 run,
19323 &project_path,
19324 out_root.to_str(),
19325 "organic",
19326 0,
19327 false,
19328 );
19329 }
19330
19331 let registry_path = out_root.join("registry.json");
19333 let mut registry = ScanRegistry::load(®istry_path);
19334 let entry = build_run_registry_entry(run, run_id, &project_label, &artifacts);
19335 registry.add_entry(entry);
19336 registry
19337 .save(®istry_path)
19338 .with_context(|| format!("failed to write registry to {}", registry_path.display()))?;
19339
19340 Ok(run_dir)
19341}
19342
19343#[allow(clippy::too_many_arguments)]
19346#[allow(clippy::too_many_lines)]
19347#[allow(clippy::similar_names)]
19348fn generate_offline_index(
19349 run: &sloc_core::AnalysisRun,
19350 run_dir: &Path,
19351 file_stem: &str,
19352 html_path: Option<&Path>,
19353 pdf_path: Option<&Path>,
19354 json_path: Option<&Path>,
19355 scan_config_path: Option<&Path>,
19356 result_context: &RunResultContext,
19357) {
19358 let Ok(run_dir) = sloc_core::reject_traversal(run_dir) else {
19359 return;
19360 };
19361 let run_dir = run_dir.as_path();
19362 let prev_entry = &result_context.prev_entry;
19363 let prev_scan_count = result_context.prev_scan_count;
19364 let project_path = &result_context.project_path;
19365
19366 let scan_delta = prev_entry.as_ref().and_then(|prev| {
19367 prev.json_path
19368 .as_ref()
19369 .and_then(|p| read_json(p).ok())
19370 .map(|prev_run| compute_delta(&prev_run, run))
19371 });
19372
19373 let files_analyzed = run.per_file_records.len() as u64;
19374 let files_skipped = run.skipped_file_records.len() as u64;
19375 let totals = sum_lang_totals(run);
19376
19377 let DeltaFields {
19378 prev_fa_str,
19379 prev_fs_str,
19380 prev_pl_str,
19381 prev_cl_str,
19382 prev_cml_str,
19383 prev_bl_str,
19384 delta_fa_str,
19385 delta_fa_class,
19386 delta_fs_str,
19387 delta_fs_class,
19388 delta_pl_str,
19389 delta_pl_class,
19390 delta_cl_str,
19391 delta_cl_class,
19392 delta_cml_str,
19393 delta_cml_class,
19394 delta_bl_str,
19395 delta_bl_class,
19396 delta_lines_added,
19397 delta_lines_removed,
19398 delta_lines_net_str,
19399 delta_lines_net_class,
19400 } = compute_delta_fields(
19401 prev_entry.as_ref(),
19402 &totals,
19403 files_analyzed,
19404 files_skipped,
19405 scan_delta.as_ref(),
19406 );
19407
19408 let git_commit_url = git_commit_url_for(run);
19409 let git_branch_url = git_branch_url_for(run);
19410 let scan_performed_by = scan_performed_by(run);
19411
19412 let make_rel = |p: Option<&Path>| -> Option<String> {
19414 p.and_then(|abs| abs.strip_prefix(run_dir).ok())
19415 .map(|rel| rel.to_string_lossy().replace('\\', "/"))
19416 };
19417
19418 let run_id = &run.tool.run_id;
19419
19420 let submodule_rows: Vec<SubmoduleRow> = run
19422 .submodule_summaries
19423 .iter()
19424 .map(|s| {
19425 let safe = sanitize_project_label(&s.name);
19426 let key = format!("sub_{safe}");
19427 let sub_path = run_dir.join("submodules").join(format!("{key}.html"));
19428 SubmoduleRow {
19429 name: s.name.clone(),
19430 relative_path: s.relative_path.clone(),
19431 files_analyzed: s.files_analyzed,
19432 code_lines: s.code_lines,
19433 comment_lines: s.comment_lines,
19434 blank_lines: s.blank_lines,
19435 total_physical_lines: s.total_physical_lines,
19436 html_url: if sub_path.exists() {
19437 Some(format!("submodules/{key}.html"))
19438 } else {
19439 None
19440 },
19441 }
19442 })
19443 .collect();
19444
19445 let lang_chart_json = build_lang_chart_json(run);
19446
19447 let scan_config_rel =
19448 make_rel(scan_config_path).unwrap_or_else(|| format!("json/scan-config_{file_stem}.json"));
19449
19450 let template = ResultTemplate {
19451 version: env!("CARGO_PKG_VERSION"),
19452 report_title: run.effective_configuration.reporting.report_title.clone(),
19453 project_path: project_path.clone(),
19454 output_dir: display_path(run_dir),
19455 run_id: run_id.clone(),
19456 run_id_short: run_id
19457 .split('-')
19458 .next_back()
19459 .unwrap_or(run_id)
19460 .chars()
19461 .take(7)
19462 .collect(),
19463 files_analyzed,
19464 files_skipped,
19465 physical_lines: totals.physical_lines,
19466 code_lines: totals.code_lines,
19467 comment_lines: totals.comment_lines,
19468 blank_lines: totals.blank_lines,
19469 mixed_lines: totals.mixed_lines,
19470 functions: totals.functions,
19471 classes: totals.classes,
19472 variables: totals.variables,
19473 imports: totals.imports,
19474 html_url: make_rel(html_path),
19475 pdf_url: make_rel(pdf_path),
19476 json_url: make_rel(json_path),
19477 html_download_url: make_rel(html_path),
19478 pdf_download_url: make_rel(pdf_path),
19479 json_download_url: make_rel(json_path),
19480 html_path: html_path.map(display_path),
19481 json_path: json_path.map(display_path),
19482 prev_run_id: prev_entry.as_ref().map(|e| e.run_id.clone()),
19483 prev_run_timestamp: prev_entry.as_ref().map(|e| fmt_la_time(e.timestamp_utc)),
19484 prev_run_code_lines: prev_entry.as_ref().map(|e| e.summary.code_lines),
19485 prev_fa_str,
19486 prev_fs_str,
19487 prev_pl_str,
19488 prev_cl_str,
19489 prev_cml_str,
19490 prev_bl_str,
19491 delta_fa_str,
19492 delta_fa_class,
19493 delta_fs_str,
19494 delta_fs_class,
19495 delta_pl_str,
19496 delta_pl_class,
19497 delta_cl_str,
19498 delta_cl_class,
19499 delta_cml_str,
19500 delta_cml_class,
19501 delta_bl_str,
19502 delta_bl_class,
19503 delta_lines_added,
19504 delta_lines_removed,
19505 delta_lines_net_str,
19506 delta_lines_net_class,
19507 delta_files_added: scan_delta.as_ref().map(|d| d.files_added),
19508 delta_files_removed: scan_delta.as_ref().map(|d| d.files_removed),
19509 delta_files_modified: scan_delta.as_ref().map(|d| d.files_modified),
19510 delta_files_unchanged: scan_delta.as_ref().map(|d| d.files_unchanged),
19511 delta_files_total: scan_delta.as_ref().map(|d| d.files_total),
19512 delta_unmodified_lines: scan_delta.as_ref().map(delta_unmodified_lines),
19513 git_branch: run.git_branch.clone(),
19514 git_branch_url,
19515 git_commit: run.git_commit_short.clone(),
19516 git_commit_long: run.git_commit_long.clone(),
19517 git_author: run.git_commit_author.clone(),
19518 git_commit_url,
19519 scan_performed_by,
19520 scan_time_display: fmt_la_time_meta(run.tool.timestamp_utc),
19521 scan_time_utc_ms: run.tool.timestamp_utc.timestamp_millis(),
19522 os_display: format!(
19523 "{} / {}",
19524 run.environment.operating_system, run.environment.architecture
19525 ),
19526 test_count: run.summary_totals.test_count,
19527 test_assertion_count: run.summary_totals.test_assertion_count,
19528 current_scan_number: prev_scan_count + 1,
19529 prev_scan_count,
19530 submodule_rows,
19531 pdf_generating: false,
19532 scan_config_url: scan_config_rel,
19533 lang_chart_json,
19534 scatter_chart_json: build_scatter_chart_json(run),
19535 semantic_chart_json: build_semantic_chart_json(run),
19536 submodule_chart_json: build_submodule_chart_json(run),
19537 has_submodule_data: !run.submodule_summaries.is_empty(),
19538 has_semantic_data: run
19539 .totals_by_language
19540 .iter()
19541 .any(|l| l.functions > 0 || l.classes > 0 || l.test_count > 0),
19542 csp_nonce: String::new(),
19543 confluence_configured: false,
19544 server_mode: false,
19545 report_header_footer: run
19546 .effective_configuration
19547 .reporting
19548 .report_header_footer
19549 .clone(),
19550 is_offline: true,
19551 cyclomatic_complexity: run.summary_totals.cyclomatic_complexity,
19552 lsloc: run.summary_totals.lsloc,
19553 uloc: run.uloc,
19554 dryness_pct_str: run.dryness_pct.map_or(String::new(), |d| format!("{d:.1}")),
19555 duplicate_group_count: run.duplicate_groups.len(),
19556 has_cocomo: run.cocomo.is_some(),
19557 cocomo_effort_str: run
19558 .cocomo
19559 .as_ref()
19560 .map_or(String::new(), |c| format!("{:.2}", c.effort_person_months)),
19561 cocomo_duration_str: run
19562 .cocomo
19563 .as_ref()
19564 .map_or(String::new(), |c| format!("{:.2}", c.duration_months)),
19565 cocomo_staff_str: run
19566 .cocomo
19567 .as_ref()
19568 .map_or(String::new(), |c| format!("{:.2}", c.avg_staff)),
19569 cocomo_ksloc_str: run
19570 .cocomo
19571 .as_ref()
19572 .map_or(String::new(), |c| format!("{:.2}", c.ksloc)),
19573 cocomo_mode_label: run.cocomo.as_ref().map_or_else(
19574 || "Organic".to_string(),
19575 |c| cocomo_mode_label(c.mode).to_string(),
19576 ),
19577 cocomo_mode_tooltip: run
19578 .cocomo
19579 .as_ref()
19580 .map_or(String::new(), |c| cocomo_mode_tooltip(c.mode).to_string()),
19581 complexity_alert: 0,
19582 has_coverage_data: run.summary_totals.coverage_lines_found > 0,
19583 cov_line_pct: cov_pct_str(
19584 run.summary_totals.coverage_lines_hit,
19585 run.summary_totals.coverage_lines_found,
19586 ),
19587 cov_fn_pct: cov_pct_str(
19588 run.summary_totals.coverage_functions_hit,
19589 run.summary_totals.coverage_functions_found,
19590 ),
19591 cov_branch_pct: cov_pct_str(
19592 run.summary_totals.coverage_branches_hit,
19593 run.summary_totals.coverage_branches_found,
19594 ),
19595 cov_lines_summary: cov_lines_summary_str(
19596 run.summary_totals.coverage_lines_hit,
19597 run.summary_totals.coverage_lines_found,
19598 ),
19599 ownership_html: String::new(),
19602 };
19603
19604 if let Ok(html) = template.render() {
19605 let html = inline_offline_logos(&html);
19609 let index_path = run_dir.join("index.html");
19610 if let Err(e) = fs::write(&index_path, html) {
19611 eprintln!("[oxide-sloc] index.html write failed (non-fatal): {e:#}");
19612 }
19613 }
19614}
19615
19616fn inline_offline_logos(html: &str) -> String {
19620 use base64::Engine;
19621 let text_uri = format!(
19622 "data:image/png;base64,{}",
19623 base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_TEXT)
19624 );
19625 let small_uri = format!(
19626 "data:image/png;base64,{}",
19627 base64::engine::general_purpose::STANDARD.encode(IMG_LOGO_SMALL)
19628 );
19629 html.replace("/images/logo/logo-text.png", &text_uri)
19630 .replace("/images/logo/small-logo.png", &small_uri)
19631}
19632
19633fn find_scan_config_in_dir(dir: &Path) -> Option<PathBuf> {
19636 if let Some(found) = find_scan_config_in_dir_flat(&dir.join("json")) {
19638 return Some(found);
19639 }
19640 find_scan_config_in_dir_flat(dir)
19642}
19643
19644fn find_scan_config_in_dir_flat(dir: &Path) -> Option<PathBuf> {
19645 let exact = dir.join("scan-config.json");
19646 if exact.exists() {
19647 return Some(exact);
19648 }
19649 fs::read_dir(dir).ok().and_then(|entries| {
19650 entries
19651 .filter_map(std::result::Result::ok)
19652 .find(|e| {
19653 let name = e.file_name();
19654 let name = name.to_string_lossy();
19655 name.starts_with("scan-config") && name.ends_with(".json")
19656 })
19657 .map(|e| e.path())
19658 })
19659}
19660
19661#[derive(Deserialize)]
19666struct ExportPdfRequest {
19667 html: String,
19668 #[serde(default)]
19669 filename: Option<String>,
19670}
19671
19672async fn export_pdf_handler(Json(body): Json<ExportPdfRequest>) -> impl IntoResponse {
19673 let html_content = body.html;
19674 let filename = body.filename.unwrap_or_else(|| "report.pdf".to_string());
19675 if html_content.is_empty() {
19676 return (StatusCode::BAD_REQUEST, "Missing html field").into_response();
19677 }
19678 let tmp_dir = std::env::temp_dir();
19680 let html_path = tmp_dir.join(format!(
19681 "sloc-export-{}.html",
19682 uuid::Uuid::new_v4().simple()
19683 ));
19684 let pdf_path = tmp_dir.join(format!("sloc-export-{}.pdf", uuid::Uuid::new_v4().simple()));
19685 if let Err(e) = std::fs::write(&html_path, &html_content) {
19686 return (
19687 StatusCode::INTERNAL_SERVER_ERROR,
19688 format!("Failed to write temp HTML: {e}"),
19689 )
19690 .into_response();
19691 }
19692 let pdf_result = write_pdf_from_html(&html_path, &pdf_path);
19693 let _ = std::fs::remove_file(&html_path);
19694 if let Err(e) = pdf_result {
19695 let _ = std::fs::remove_file(&pdf_path);
19696 return (
19697 StatusCode::INTERNAL_SERVER_ERROR,
19698 format!("PDF generation failed: {e}"),
19699 )
19700 .into_response();
19701 }
19702 let pdf_bytes = match std::fs::read(&pdf_path) {
19703 Ok(b) => b,
19704 Err(e) => {
19705 let _ = std::fs::remove_file(&pdf_path);
19706 return (
19707 StatusCode::INTERNAL_SERVER_ERROR,
19708 format!("Failed to read PDF: {e}"),
19709 )
19710 .into_response();
19711 }
19712 };
19713 let _ = std::fs::remove_file(&pdf_path);
19714 let safe_name: String = filename
19715 .chars()
19716 .map(|c| {
19717 if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
19718 c
19719 } else {
19720 '_'
19721 }
19722 })
19723 .collect();
19724 let disposition = format!("attachment; filename=\"{safe_name}\"");
19725 (
19726 [
19727 (header::CONTENT_TYPE, "application/pdf".to_string()),
19728 (header::CONTENT_DISPOSITION, disposition),
19729 ],
19730 pdf_bytes,
19731 )
19732 .into_response()
19733}
19734
19735async fn export_config_handler(State(state): State<AppState>) -> impl IntoResponse {
19736 let toml_str = match toml::to_string_pretty(&state.base_config) {
19737 Ok(s) => s,
19738 Err(e) => {
19739 return (
19740 StatusCode::INTERNAL_SERVER_ERROR,
19741 format!("serialization error: {e}"),
19742 )
19743 .into_response();
19744 }
19745 };
19746 (
19747 [
19748 (header::CONTENT_TYPE, "application/toml; charset=utf-8"),
19749 (
19750 header::CONTENT_DISPOSITION,
19751 "attachment; filename=\".oxide-sloc.toml\"",
19752 ),
19753 ],
19754 toml_str,
19755 )
19756 .into_response()
19757}
19758
19759#[derive(Serialize)]
19760struct OkResponse {
19761 ok: bool,
19762}
19763
19764#[derive(Serialize)]
19765struct SaveProfileResponse {
19766 ok: bool,
19767 id: String,
19768}
19769
19770#[derive(Serialize)]
19771struct ProfileListResponse {
19772 profiles: Vec<ScanProfile>,
19773}
19774
19775#[derive(Serialize)]
19776struct ImportConfigResponse {
19777 ok: bool,
19778 config: sloc_config::AppConfig,
19779}
19780
19781#[derive(Deserialize)]
19782struct ImportConfigBody {
19783 toml: String,
19784}
19785
19786async fn import_config_handler(Json(body): Json<ImportConfigBody>) -> impl IntoResponse {
19787 match toml::from_str::<sloc_config::AppConfig>(&body.toml) {
19788 Ok(config) => {
19789 if let Err(e) = config.validate() {
19790 return error::unprocessable_entity(&e.to_string());
19791 }
19792 Json(ImportConfigResponse { ok: true, config }).into_response()
19793 }
19794 Err(e) => error::bad_request(&format!("TOML parse error: {e}")),
19795 }
19796}
19797
19798async fn api_list_scan_profiles(State(state): State<AppState>) -> impl IntoResponse {
19801 let store = state.scan_profiles.lock().await;
19802 Json(ProfileListResponse {
19803 profiles: store.profiles.clone(),
19804 })
19805}
19806
19807#[derive(Deserialize)]
19808struct SaveScanProfileBody {
19809 name: String,
19810 params: serde_json::Value,
19811}
19812
19813async fn api_save_scan_profile(
19814 State(state): State<AppState>,
19815 Json(body): Json<SaveScanProfileBody>,
19816) -> impl IntoResponse {
19817 if body.name.trim().is_empty() {
19818 return error::bad_request("name must not be empty");
19819 }
19820
19821 let id = uuid::Uuid::new_v4().to_string();
19822 let profile = ScanProfile {
19823 id: id.clone(),
19824 name: body.name.trim().to_string(),
19825 created_at: chrono::Utc::now().to_rfc3339(),
19826 params: body.params,
19827 };
19828
19829 let mut store = state.scan_profiles.lock().await;
19830 store.profiles.push(profile);
19831 if let Err(e) = store.save(&state.scan_profiles_path) {
19832 tracing::warn!("failed to persist scan profiles: {e}");
19833 }
19834 drop(store);
19835
19836 (
19837 StatusCode::CREATED,
19838 Json(SaveProfileResponse { ok: true, id }),
19839 )
19840 .into_response()
19841}
19842
19843async fn api_delete_scan_profile(
19844 State(state): State<AppState>,
19845 AxumPath(id): AxumPath<String>,
19846) -> impl IntoResponse {
19847 let mut store = state.scan_profiles.lock().await;
19848 let before = store.profiles.len();
19849 store.profiles.retain(|p| p.id != id);
19850 if store.profiles.len() == before {
19851 drop(store);
19852 return error::not_found("profile not found");
19853 }
19854 if let Err(e) = store.save(&state.scan_profiles_path) {
19855 tracing::warn!("failed to persist scan profiles: {e}");
19856 }
19857 drop(store);
19858 Json(OkResponse { ok: true }).into_response()
19859}
19860
19861fn resolve_output_root(raw: Option<&str>) -> PathBuf {
19862 let value = raw.unwrap_or("out/web").trim();
19863 let path = if value.is_empty() {
19864 PathBuf::from("out/web")
19865 } else {
19866 PathBuf::from(value)
19867 };
19868
19869 if path.is_absolute() {
19870 path
19871 } else {
19872 workspace_root().join(path)
19873 }
19874}
19875
19876fn resolve_git_clones_dir(output_root: &Path) -> PathBuf {
19878 std::env::var("SLOC_GIT_CLONES_DIR")
19879 .map_or_else(|_| output_root.join("git-clones"), PathBuf::from)
19880}
19881
19882pub(crate) fn git_clone_dest(repo_url: &str, clones_dir: &Path) -> PathBuf {
19885 let safe: String = repo_url
19886 .chars()
19887 .map(|c| {
19888 if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
19889 c
19890 } else {
19891 '_'
19892 }
19893 })
19894 .take(80)
19895 .collect();
19896 clones_dir.join(safe)
19897}
19898
19899pub(crate) fn scan_path_to_artifacts(
19902 scan_path: &Path,
19903 base_config: &AppConfig,
19904 label: &str,
19905) -> Result<(String, RunArtifacts, sloc_core::AnalysisRun)> {
19906 let mut config = base_config.clone();
19907 config.discovery.root_paths = vec![scan_path.to_path_buf()];
19908 label.clone_into(&mut config.reporting.report_title);
19909 let run = analyze(&config, "git", None, None)?;
19910 let html = render_html(&run)?;
19911 let run_id = run.tool.run_id.clone();
19912 let project_label = sanitize_project_label(label);
19913 let output_dir = resolve_output_root(None).join(derive_run_dir_name(
19914 &project_label,
19915 run.git_branch.as_deref(),
19916 &run_id,
19917 ));
19918 let file_stem = {
19919 let commit = run.git_commit_short.as_deref().unwrap_or("").trim();
19920 if commit.is_empty() {
19921 project_label
19922 } else {
19923 format!("{project_label}_{commit}")
19924 }
19925 };
19926 let (artifacts, _pending_pdf) = persist_run_artifacts(
19927 &run,
19928 &html,
19929 &output_dir,
19930 label,
19931 &file_stem,
19932 RunResultContext::default(),
19933 )?;
19934 Ok((run_id, artifacts, run))
19935}
19936
19937async fn restart_poll_schedules(state: &AppState) {
19939 let store = state.schedules.lock().await;
19940 let poll_schedules: Vec<_> = store
19941 .schedules
19942 .iter()
19943 .filter(|s| s.kind == sloc_git::ScanScheduleKind::Poll && s.enabled)
19944 .cloned()
19945 .collect();
19946 drop(store);
19947 for schedule in poll_schedules {
19948 let interval = schedule.interval_secs.unwrap_or(300);
19949 let st = state.clone();
19950 tokio::spawn(async move { git_webhook::poll_loop(st, schedule, interval).await });
19951 }
19952}
19953
19954async fn warn_insecure_gitlab_webhooks(state: &AppState) {
19960 if state.tls_enabled {
19961 return;
19962 }
19963 let store = state.schedules.lock().await;
19964 let has_gitlab_webhook = store.schedules.iter().any(|s| {
19965 s.kind == sloc_git::ScanScheduleKind::Webhook
19966 && s.provider == sloc_git::ScanScheduleProvider::GitLab
19967 });
19968 drop(store);
19969 if has_gitlab_webhook {
19970 tracing::warn!(
19971 "GitLab webhook schedule(s) configured but native TLS is not enabled. \
19972 GitLab sends its webhook token as a plaintext X-Gitlab-Token header; \
19973 terminate TLS here (SLOC_TLS_CERT/SLOC_TLS_KEY) or at an upstream reverse \
19974 proxy so the token is not exposed in cleartext."
19975 );
19976 }
19977}
19978
19979fn split_patterns(raw: Option<&str>) -> Vec<String> {
19980 raw.unwrap_or("")
19981 .lines()
19982 .flat_map(|line| line.split(','))
19983 .map(str::trim)
19984 .filter(|part| !part.is_empty())
19985 .map(ToOwned::to_owned)
19986 .collect()
19987}
19988
19989#[must_use]
19990pub fn build_sub_run(
19991 parent: &AnalysisRun,
19992 sub: &sloc_core::SubmoduleSummary,
19993 parent_path: &str,
19994) -> AnalysisRun {
19995 let mut sub_files: Vec<_> = parent
19996 .per_file_records
19997 .iter()
19998 .filter(|r| r.submodule.as_deref() == Some(sub.name.as_str()))
19999 .cloned()
20000 .collect();
20001 let mut config = parent.effective_configuration.clone();
20002 config.reporting.report_title = format!("{} — {}", config.reporting.report_title, sub.name);
20003
20004 let mut functions = 0u64;
20006 let mut classes = 0u64;
20007 let mut variables = 0u64;
20008 let mut imports = 0u64;
20009 let mut test_count = 0u64;
20010 let mut test_assertion_count = 0u64;
20011 let mut test_suite_count = 0u64;
20012 let mut mixed_lines_separate = 0u64;
20013 let mut coverage_lines_found = 0u64;
20014 let mut coverage_lines_hit = 0u64;
20015 let mut coverage_functions_found = 0u64;
20016 let mut coverage_functions_hit = 0u64;
20017 let mut coverage_branches_found = 0u64;
20018 let mut coverage_branches_hit = 0u64;
20019 for r in &sub_files {
20020 functions += r.raw_line_categories.functions;
20021 classes += r.raw_line_categories.classes;
20022 variables += r.raw_line_categories.variables;
20023 imports += r.raw_line_categories.imports;
20024 test_count += r.raw_line_categories.test_count;
20025 test_assertion_count += r.raw_line_categories.test_assertion_count;
20026 test_suite_count += r.raw_line_categories.test_suite_count;
20027 mixed_lines_separate += r.effective_counts.mixed_lines_separate;
20028 if let Some(cov) = &r.coverage {
20029 coverage_lines_found += u64::from(cov.lines_found);
20030 coverage_lines_hit += u64::from(cov.lines_hit);
20031 coverage_functions_found += u64::from(cov.functions_found);
20032 coverage_functions_hit += u64::from(cov.functions_hit);
20033 coverage_branches_found += u64::from(cov.branches_found);
20034 coverage_branches_hit += u64::from(cov.branches_hit);
20035 }
20036 }
20037
20038 let authors = sloc_core::scope_authors_to_records(&parent.authors, &mut sub_files);
20043
20044 AnalysisRun {
20045 tool: parent.tool.clone(),
20046 environment: parent.environment.clone(),
20047 effective_configuration: config,
20048 input_roots: vec![format!("{}/{}", parent_path, sub.relative_path)],
20049 summary_totals: SummaryTotals {
20050 files_considered: sub.files_analyzed,
20051 files_analyzed: sub.files_analyzed,
20052 files_skipped: 0,
20053 total_physical_lines: sub.total_physical_lines,
20054 code_lines: sub.code_lines,
20055 comment_lines: sub.comment_lines,
20056 blank_lines: sub.blank_lines,
20057 mixed_lines_separate,
20058 functions,
20059 classes,
20060 variables,
20061 imports,
20062 test_count,
20063 test_assertion_count,
20064 test_suite_count,
20065 coverage_lines_found,
20066 coverage_lines_hit,
20067 coverage_functions_found,
20068 coverage_functions_hit,
20069 coverage_branches_found,
20070 coverage_branches_hit,
20071 cyclomatic_complexity: 0,
20072 lsloc: None,
20073 ..Default::default()
20074 },
20075 totals_by_language: sub.language_summaries.clone(),
20076 per_file_records: sub_files,
20077 skipped_file_records: vec![],
20078 warnings: vec![],
20079 submodule_summaries: vec![],
20080 git_commit_short: sub.git_commit_short.clone(),
20081 git_commit_long: sub.git_commit_long.clone(),
20082 git_branch: sub.git_branch.clone(),
20083 git_commit_author: sub.git_commit_author.clone(),
20084 git_commit_date: sub.git_commit_date.clone(),
20085 git_tags: None,
20086 git_nearest_tag: None,
20087 git_remote_url: sub.git_remote_url.clone(),
20088 style_summary: None,
20089 cocomo: None,
20090 uloc: 0,
20091 dryness_pct: None,
20092 duplicate_groups: vec![],
20093 duplicates_excluded: 0,
20094 authors,
20095 }
20096}
20097
20098#[must_use]
20099pub fn sanitize_project_label(raw: &str) -> String {
20100 let candidate = raw
20103 .split(['/', '\\'])
20104 .rfind(|s| !s.is_empty())
20105 .unwrap_or("project");
20106
20107 let mut value = String::with_capacity(candidate.len());
20108 for ch in candidate.chars() {
20109 if ch.is_ascii_alphanumeric() {
20110 value.push(ch.to_ascii_lowercase());
20111 } else {
20112 value.push('-');
20113 }
20114 }
20115
20116 let compact = value.trim_matches('-').to_string();
20117 if compact.is_empty() {
20118 "project".to_string()
20119 } else {
20120 compact
20121 }
20122}
20123
20124fn strip_unc_prefix(path: PathBuf) -> PathBuf {
20127 let s = path.to_string_lossy();
20128 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
20129 return PathBuf::from(format!(r"\\{rest}"));
20130 }
20131 if let Some(rest) = s.strip_prefix(r"\\?\") {
20132 return PathBuf::from(rest);
20133 }
20134 path
20135}
20136
20137fn remote_to_commit_url(remote: &str, sha: &str) -> Option<String> {
20140 let base = if let Some(rest) = remote.strip_prefix("git@") {
20141 let (host, path) = rest.split_once(':')?;
20142 format!("https://{}/{}", host, path.trim_end_matches(".git"))
20143 } else if remote.starts_with("https://") || remote.starts_with("http://") {
20144 remote
20145 .trim_end_matches('/')
20146 .trim_end_matches(".git")
20147 .to_owned()
20148 } else {
20149 return None;
20150 };
20151 let base = base.trim_end_matches('/');
20152 if base.contains("gitlab.com") || base.contains("gitlab.") {
20154 Some(format!("{base}/-/commit/{sha}"))
20155 } else if base.contains("bitbucket.org") {
20156 Some(format!("{base}/commits/{sha}"))
20157 } else {
20158 Some(format!("{base}/commit/{sha}"))
20159 }
20160}
20161
20162fn remote_to_branch_url(remote: &str, branch: &str) -> Option<String> {
20165 let base = if let Some(rest) = remote.strip_prefix("git@") {
20166 let (host, path) = rest.split_once(':')?;
20167 format!("https://{}/{}", host, path.trim_end_matches(".git"))
20168 } else if remote.starts_with("https://") || remote.starts_with("http://") {
20169 remote
20170 .trim_end_matches('/')
20171 .trim_end_matches(".git")
20172 .to_owned()
20173 } else {
20174 return None;
20175 };
20176 let base = base.trim_end_matches('/');
20177 if base.contains("gitlab.com") || base.contains("gitlab.") {
20178 Some(format!("{base}/-/tree/{branch}"))
20179 } else {
20180 Some(format!("{base}/tree/{branch}"))
20181 }
20182}
20183
20184fn display_path(path: &Path) -> String {
20185 let s = path.to_string_lossy();
20186 if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
20191 return format!(r"\\{rest}");
20192 }
20193 if let Some(rest) = s.strip_prefix(r"\\?\") {
20194 return rest.to_owned();
20195 }
20196 s.into_owned()
20197}
20198
20199fn sanitize_path_str(s: &str) -> String {
20200 if let Some(rest) = s.strip_prefix("//?/UNC/") {
20204 return format!("//{rest}");
20205 }
20206 if let Some(rest) = s.strip_prefix("//?/") {
20207 return rest.to_owned();
20208 }
20209 display_path(Path::new(s))
20210}
20211
20212fn workspace_root() -> PathBuf {
20213 if let Ok(root) = std::env::var("OXIDE_SLOC_ROOT") {
20215 let p = PathBuf::from(root);
20216 if p.is_dir() {
20217 return p;
20218 }
20219 }
20220
20221 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
20224}
20225
20226fn make_git_label(repo: &str, ref_name: &str) -> String {
20228 if repo.is_empty() || ref_name.is_empty() {
20229 return String::new();
20230 }
20231 let base = repo
20232 .trim_end_matches('/')
20233 .trim_end_matches(".git")
20234 .rsplit('/')
20235 .next()
20236 .unwrap_or("repo");
20237 let ref_safe: String = ref_name
20238 .chars()
20239 .map(|c| {
20240 if c.is_alphanumeric() || c == '-' || c == '.' {
20241 c
20242 } else {
20243 '_'
20244 }
20245 })
20246 .collect();
20247 format!("{base}_at_{ref_safe}_sloc")
20248}
20249
20250fn desktop_dir() -> PathBuf {
20252 if let Ok(profile) = std::env::var("USERPROFILE") {
20253 let p = PathBuf::from(profile).join("Desktop");
20254 if p.exists() {
20255 return p;
20256 }
20257 }
20258 if let Ok(home) = std::env::var("HOME") {
20259 let p = PathBuf::from(home).join("Desktop");
20260 if p.exists() {
20261 return p;
20262 }
20263 }
20264 workspace_root().join("out").join("web")
20265}
20266
20267fn resolve_input_path(raw: &str) -> PathBuf {
20268 let trimmed = raw.trim();
20269 if trimmed.is_empty() {
20270 return workspace_root().join("samples").join("basic");
20271 }
20272
20273 let candidate = PathBuf::from(trimmed);
20274 let resolved = if candidate.is_absolute() {
20275 candidate
20276 } else {
20277 let rooted = workspace_root().join(&candidate);
20278 if rooted.exists() {
20279 rooted
20280 } else {
20281 workspace_root().join(candidate)
20282 }
20283 };
20284
20285 let canonical = fs::canonicalize(&resolved).unwrap_or(resolved);
20288 PathBuf::from(display_path(&canonical))
20289}
20290
20291fn dir_size_bytes(path: &Path) -> u64 {
20297 let mut total = 0u64;
20298 let mut stack = vec![path.to_path_buf()];
20299 while let Some(dir) = stack.pop() {
20300 let Ok(rd) = fs::read_dir(&dir) else { continue };
20301 for entry in rd.filter_map(Result::ok) {
20302 let Ok(ft) = entry.file_type() else { continue };
20303 if ft.is_symlink() {
20304 continue;
20305 }
20306 if ft.is_dir() {
20307 stack.push(entry.path());
20308 } else if let Ok(meta) = entry.metadata() {
20309 total = total.saturating_add(meta.len());
20310 }
20311 }
20312 }
20313 total
20314}
20315
20316#[allow(clippy::cast_precision_loss)] fn format_dir_size(bytes: u64) -> String {
20318 if bytes >= 1_073_741_824 {
20319 format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
20320 } else if bytes >= 1_048_576 {
20321 format!("{:.1} MB", bytes as f64 / 1_048_576.0)
20322 } else if bytes >= 1_024 {
20323 format!("{:.0} KB", bytes as f64 / 1_024.0)
20324 } else {
20325 format!("{bytes} B")
20326 }
20327}
20328
20329fn render_submodule_chips(
20330 root: &Path,
20331 submodules: &[(String, std::path::PathBuf)],
20332 out: &mut String,
20333) {
20334 use std::fmt::Write as _;
20335 let count = submodules.len();
20336 out.push_str(r#"<div class="submodule-preview-strip">"#);
20337 write!(
20338 out,
20339 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>"#,
20340 if count == 1 { "" } else { "s" }
20341 )
20342 .ok();
20343 out.push_str(r#"<div class="submodule-preview-chips">"#);
20344 for (sub_name, sub_rel_path) in submodules {
20345 let sub_abs = root.join(sub_rel_path);
20346 let sub_size = format_dir_size(dir_size_bytes(&sub_abs));
20347 let mut sub_stats = PreviewStats::default();
20348 let mut sub_rows: Vec<PreviewRow> = Vec::new();
20349 let mut sub_langs: Vec<&'static str> = Vec::new();
20350 let mut sub_budget = PreviewBudget {
20351 shown: 0,
20352 max_entries: 2000,
20353 max_depth: 9,
20354 };
20355 let mut sub_next_id = 1usize;
20356 let _ = collect_preview_rows(
20357 &sub_abs,
20358 &sub_abs,
20359 0,
20360 None,
20361 &mut sub_next_id,
20362 &mut sub_budget,
20363 &mut sub_stats,
20364 &mut sub_rows,
20365 &mut sub_langs,
20366 &[],
20367 &[],
20368 );
20369 let stats_json = format!(
20370 r#"{{"dirs":{},"files":{},"supported":{},"skipped":{},"unsupported":{}}}"#,
20371 sub_stats.directories,
20372 sub_stats.files,
20373 sub_stats.supported,
20374 sub_stats.skipped,
20375 sub_stats.unsupported
20376 );
20377 write!(
20378 out,
20379 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>"#,
20380 escape_html(sub_name),
20381 escape_html(&sub_rel_path.to_string_lossy()),
20382 escape_html(&sub_size),
20383 escape_html(&stats_json),
20384 escape_html(sub_name),
20385 escape_html(&sub_size),
20386 )
20387 .ok();
20388 }
20389 out.push_str(
20390 r#"</div><button type="button" class="submodule-base-repo-btn sx-6aa34d74" >↑ Base repo</button>"#,
20391 );
20392 out.push_str(r"</div>");
20393}
20394
20395fn render_multi_repo_warning(root: &Path, layout: &sloc_core::RepositoryLayout, out: &mut String) {
20399 use std::fmt::Write as _;
20400 const MAX_LISTED: usize = 5;
20401 let total = layout.nested_repos.len();
20402
20403 out.push_str(r#"<div class="preview-warning" data-multi-repo="1">"#);
20404 if layout.root_is_repo {
20405 write!(
20406 out,
20407 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>",
20408 if total == 1 { "repository" } else { "repositories" }
20409 )
20410 .ok();
20411 } else {
20412 write!(
20413 out,
20414 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>"
20415 )
20416 .ok();
20417 }
20418
20419 out.push_str(r#"<div class="repo-pick-row">"#);
20420 for rel in layout.nested_repos.iter().take(MAX_LISTED) {
20421 let abs = root.join(rel);
20422 let abs_display = display_path(&abs);
20423 let label = rel.to_string_lossy().replace('\\', "/");
20424 write!(
20425 out,
20426 r#"<button type="button" class="repo-pick" data-repo-path="{}">{}</button>"#,
20427 escape_html(&abs_display),
20428 escape_html(&label)
20429 )
20430 .ok();
20431 }
20432 if total > MAX_LISTED {
20433 write!(
20434 out,
20435 r#"<span class="repo-pick-more">and {} more</span>"#,
20436 total - MAX_LISTED
20437 )
20438 .ok();
20439 }
20440 out.push_str(r"</div>");
20441
20442 out.push_str(r#"<label class="multi-repo-ack-label"><input type="checkbox" class="multi-repo-ack" /> I understand — scan this folder anyway</label>"#);
20443 out.push_str(r"</div>");
20444}
20445
20446fn render_language_pills_row(languages: &[&str], out: &mut String) {
20447 use std::fmt::Write as _;
20448 if languages.is_empty() {
20449 out.push_str(
20450 r#"<span class="language-pill muted-pill">No supported languages detected yet</span>"#,
20451 );
20452 return;
20453 }
20454 out.push_str(r#"<button type="button" class="language-pill detected-language-chip active" data-language-filter=""><span>All languages</span></button>"#);
20455 for language in languages {
20456 if let Some(icon) = language_icon_file(language) {
20457 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();
20458 } else if let Some(svg) = language_inline_svg(language) {
20459 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();
20460 } else {
20461 write!(
20462 out,
20463 r#"<button type="button" class="language-pill detected-language-chip" data-language-filter="{}">{}</button>"#,
20464 escape_html(&language.to_ascii_lowercase()),
20465 escape_html(language)
20466 )
20467 .ok();
20468 }
20469 }
20470}
20471
20472#[allow(clippy::too_many_lines)]
20473fn build_preview_html(
20474 root: &Path,
20475 include_patterns: &[String],
20476 exclude_patterns: &[String],
20477) -> Result<String> {
20478 if !root.exists() {
20479 return Ok(format!(
20480 r#"<div class="preview-error">Path does not exist: <code>{}</code></div>"#,
20481 escape_html(&display_path(root))
20482 ));
20483 }
20484
20485 let _selected = display_path(root);
20486 let mut stats = PreviewStats::default();
20487 let mut rows = Vec::new();
20488 let mut languages = Vec::new();
20489 let mut budget = PreviewBudget {
20490 shown: 0,
20491 max_entries: 600,
20492 max_depth: 9,
20493 };
20494 let mut next_row_id = 1usize;
20495
20496 let root_name = root.file_name().and_then(|name| name.to_str()).map_or_else(
20497 || root.to_string_lossy().into_owned(),
20498 std::string::ToString::to_string,
20499 );
20500 let root_modified = root
20501 .metadata()
20502 .ok()
20503 .and_then(|meta| meta.modified().ok())
20504 .map_or_else(|| "-".to_string(), format_system_time);
20505
20506 rows.push(PreviewRow {
20507 row_id: 0,
20508 parent_row_id: None,
20509 depth: 0,
20510 name: format!("{root_name}/"),
20511 kind: PreviewKind::Dir,
20512 is_dir: true,
20513 language: None,
20514 modified: root_modified,
20515 type_label: "Directory".to_string(),
20516 });
20517 collect_preview_rows(
20518 root,
20519 root,
20520 0,
20521 Some(0),
20522 &mut next_row_id,
20523 &mut budget,
20524 &mut stats,
20525 &mut rows,
20526 &mut languages,
20527 include_patterns,
20528 exclude_patterns,
20529 )?;
20530
20531 let root_size = format_dir_size(dir_size_bytes(root));
20532
20533 let mut out = String::new();
20534 write!(
20535 out,
20536 r#"<div class="explorer-wrap" data-project-size="{}">"#,
20537 escape_html(&root_size)
20538 )
20539 .ok();
20540 out.push_str(r#"<div class="explorer-toolbar compact">"#);
20541 out.push_str(r#"<div class="explorer-title-group">"#);
20542 out.push_str(r#"<div class="explorer-title">Project scope preview</div>"#);
20543 out.push_str(r#"<div class="explorer-subtitle wide">Pre-scan explorer view for the current built-in analyzers and default skip rules.</div>"#);
20544 out.push_str(r"</div></div>");
20545
20546 out.push_str(r#"<div class="scope-stats">"#);
20547 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();
20548 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();
20549 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();
20550 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();
20551 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();
20552 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>"#);
20553 out.push_str(r"</div>");
20554
20555 let submodules = sloc_core::detect_submodules(root);
20556 if !submodules.is_empty() {
20557 render_submodule_chips(root, &submodules, &mut out);
20558 }
20559
20560 let repo_layout = sloc_core::detect_repository_layout(root);
20561 if repo_layout.has_multiple_repos() {
20562 render_multi_repo_warning(root, &repo_layout, &mut out);
20563 }
20564
20565 out.push_str(r#"<div class="scope-info-row">"#);
20566 out.push_str(r#"<div class="explorer-language-strip"><div class="meta-label">Detected languages</div><div class="language-pill-row iconified">"#);
20567 render_language_pills_row(&languages, &mut out);
20568 out.push_str(r"</div></div>");
20569 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>"#);
20570 out.push_str(r"</div>");
20571
20572 out.push_str(r#"<div class="file-explorer-shell">"#);
20573 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>"#);
20574 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>"#);
20575 out.push_str(r#"<div class="file-explorer-tree">"#);
20576 for row in rows {
20577 let status_label = row.kind.label();
20578 let lang_attr = row.language.unwrap_or("");
20579 let toggle_html = if row.is_dir {
20580 r#"<button type="button" class="tree-toggle" aria-label="Toggle folder">▾</button>"#
20581 .to_string()
20582 } else {
20583 r#"<span class="tree-bullet">•</span>"#.to_string()
20584 };
20585 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" data-sx-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();
20586 }
20587 if budget.shown >= budget.max_entries {
20588 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 sx-a008a1ed" ><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>"#);
20589 }
20590 out.push_str(r"</div></div></div>");
20591
20592 Ok(out)
20593}
20594
20595#[derive(Default)]
20596struct PreviewStats {
20597 directories: usize,
20598 files: usize,
20599 supported: usize,
20600 skipped: usize,
20601 unsupported: usize,
20602}
20603
20604struct PreviewRow {
20605 row_id: usize,
20606 parent_row_id: Option<usize>,
20607 depth: usize,
20608 name: String,
20609 kind: PreviewKind,
20610 is_dir: bool,
20611 language: Option<&'static str>,
20612 modified: String,
20613 type_label: String,
20614}
20615
20616#[derive(Copy, Clone)]
20617enum PreviewKind {
20618 Dir,
20619 Supported,
20620 Skipped,
20621 Unsupported,
20622}
20623
20624impl PreviewKind {
20625 const fn filter_key(self) -> &'static str {
20626 match self {
20627 Self::Dir => "dir",
20628 Self::Supported => "supported",
20629 Self::Skipped => "skipped",
20630 Self::Unsupported => "unsupported",
20631 }
20632 }
20633
20634 const fn label(self) -> &'static str {
20635 match self {
20636 Self::Dir => "dir",
20637 Self::Supported => "supported",
20638 Self::Skipped => "skipped by policy",
20639 Self::Unsupported => "unsupported",
20640 }
20641 }
20642
20643 const fn badge_class(self) -> &'static str {
20644 match self {
20645 Self::Dir => "badge badge-dir",
20646 Self::Supported => "badge badge-scan",
20647 Self::Skipped => "badge badge-skip",
20648 Self::Unsupported => "badge badge-unsupported",
20649 }
20650 }
20651
20652 const fn node_class(self) -> &'static str {
20653 match self {
20654 Self::Dir => "tree-node-dir",
20655 Self::Supported => "tree-node-supported",
20656 Self::Skipped => "tree-node-skipped",
20657 Self::Unsupported => "tree-node-unsupported",
20658 }
20659 }
20660}
20661
20662struct PreviewBudget {
20663 shown: usize,
20664 max_entries: usize,
20665 max_depth: usize,
20666}
20667
20668#[allow(clippy::too_many_arguments)]
20671fn handle_preview_dir_entry(
20672 root: &Path,
20673 path: &Path,
20674 name: &str,
20675 modified: String,
20676 depth: usize,
20677 parent_row_id: Option<usize>,
20678 row_id: usize,
20679 next_row_id: &mut usize,
20680 budget: &mut PreviewBudget,
20681 stats: &mut PreviewStats,
20682 rows: &mut Vec<PreviewRow>,
20683 languages: &mut Vec<&'static str>,
20684 include_patterns: &[String],
20685 exclude_patterns: &[String],
20686) -> Result<()> {
20687 let relative = preview_relative_path(root, path);
20688 if should_skip_preview_directory(&relative, exclude_patterns) {
20689 return Ok(());
20690 }
20691 stats.directories += 1;
20692 rows.push(PreviewRow {
20693 row_id,
20694 parent_row_id,
20695 depth: depth + 1,
20696 name: format!("{name}/"),
20697 kind: PreviewKind::Dir,
20698 is_dir: true,
20699 language: None,
20700 modified,
20701 type_label: "Directory".to_string(),
20702 });
20703 budget.shown += 1;
20704 if !matches!(name, ".git" | "node_modules" | "target") {
20705 collect_preview_rows(
20706 root,
20707 path,
20708 depth + 1,
20709 Some(row_id),
20710 next_row_id,
20711 budget,
20712 stats,
20713 rows,
20714 languages,
20715 include_patterns,
20716 exclude_patterns,
20717 )?;
20718 }
20719 Ok(())
20720}
20721
20722#[allow(clippy::too_many_arguments)]
20724fn handle_preview_file_entry(
20725 root: &Path,
20726 path: &Path,
20727 name: &str,
20728 modified: String,
20729 depth: usize,
20730 parent_row_id: Option<usize>,
20731 row_id: usize,
20732 budget: &mut PreviewBudget,
20733 stats: &mut PreviewStats,
20734 rows: &mut Vec<PreviewRow>,
20735 languages: &mut Vec<&'static str>,
20736 include_patterns: &[String],
20737 exclude_patterns: &[String],
20738) {
20739 let relative = preview_relative_path(root, path);
20740 if !should_include_preview_file(&relative, include_patterns, exclude_patterns) {
20741 return;
20742 }
20743 stats.files += 1;
20744 let kind = classify_preview_file(name);
20745 match kind {
20746 PreviewKind::Supported => stats.supported += 1,
20747 PreviewKind::Skipped => stats.skipped += 1,
20748 PreviewKind::Unsupported => stats.unsupported += 1,
20749 PreviewKind::Dir => {}
20750 }
20751 let language = detect_language_name(name);
20752 if let Some(lang) = language
20753 && !languages.contains(&lang)
20754 {
20755 languages.push(lang);
20756 }
20757 rows.push(PreviewRow {
20758 row_id,
20759 parent_row_id,
20760 depth: depth + 1,
20761 name: name.to_owned(),
20762 kind,
20763 is_dir: false,
20764 language,
20765 modified,
20766 type_label: preview_type_label(name, language, kind),
20767 });
20768 budget.shown += 1;
20769}
20770
20771#[allow(clippy::too_many_arguments)]
20772#[allow(clippy::too_many_lines)]
20773fn collect_preview_rows(
20774 root: &Path,
20775 dir: &Path,
20776 depth: usize,
20777 parent_row_id: Option<usize>,
20778 next_row_id: &mut usize,
20779 budget: &mut PreviewBudget,
20780 stats: &mut PreviewStats,
20781 rows: &mut Vec<PreviewRow>,
20782 languages: &mut Vec<&'static str>,
20783 include_patterns: &[String],
20784 exclude_patterns: &[String],
20785) -> Result<()> {
20786 if depth >= budget.max_depth || budget.shown >= budget.max_entries {
20787 return Ok(());
20788 }
20789
20790 let mut entries = fs::read_dir(dir)
20791 .with_context(|| format!("failed to read directory {}", dir.display()))?
20792 .filter_map(std::result::Result::ok)
20793 .collect::<Vec<_>>();
20794 entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_ascii_lowercase());
20795
20796 for entry in entries {
20797 if budget.shown >= budget.max_entries {
20798 break;
20799 }
20800
20801 let path = entry.path();
20802 let name = entry.file_name().to_string_lossy().into_owned();
20803 let Ok(metadata) = entry.metadata() else {
20804 continue;
20805 };
20806 let row_id = *next_row_id;
20807 *next_row_id += 1;
20808 let modified = metadata
20809 .modified()
20810 .ok()
20811 .map_or_else(|| "-".to_string(), format_system_time);
20812
20813 if metadata.is_dir() {
20814 handle_preview_dir_entry(
20815 root,
20816 &path,
20817 &name,
20818 modified,
20819 depth,
20820 parent_row_id,
20821 row_id,
20822 next_row_id,
20823 budget,
20824 stats,
20825 rows,
20826 languages,
20827 include_patterns,
20828 exclude_patterns,
20829 )?;
20830 continue;
20831 }
20832
20833 if metadata.is_file() {
20834 handle_preview_file_entry(
20835 root,
20836 &path,
20837 &name,
20838 modified,
20839 depth,
20840 parent_row_id,
20841 row_id,
20842 budget,
20843 stats,
20844 rows,
20845 languages,
20846 include_patterns,
20847 exclude_patterns,
20848 );
20849 }
20850 }
20851
20852 Ok(())
20853}
20854
20855fn preview_type_label(name: &str, language: Option<&'static str>, kind: PreviewKind) -> String {
20856 if let Some(language) = language {
20857 return format!("{language} source");
20858 }
20859 let lower = name.to_ascii_lowercase();
20860 let ext = Path::new(&lower)
20861 .extension()
20862 .and_then(|e| e.to_str())
20863 .unwrap_or("");
20864 match kind {
20865 PreviewKind::Skipped => {
20866 if lower.ends_with(".min.js") {
20867 "Minified asset".to_string()
20868 } else if [
20869 "png", "jpg", "jpeg", "gif", "zip", "pdf", "xz", "gz", "tar", "pyc",
20870 ]
20871 .contains(&ext)
20872 {
20873 "Binary or archive".to_string()
20874 } else {
20875 "Skipped file".to_string()
20876 }
20877 }
20878 PreviewKind::Unsupported => {
20879 if ext.is_empty() {
20880 "Unsupported file".to_string()
20881 } else {
20882 format!("{} file", ext.to_ascii_uppercase())
20883 }
20884 }
20885 PreviewKind::Supported => "Supported source".to_string(),
20886 PreviewKind::Dir => "Directory".to_string(),
20887 }
20888}
20889
20890fn format_system_time(time: SystemTime) -> String {
20891 #[allow(clippy::cast_possible_wrap)]
20892 let secs = match time.duration_since(UNIX_EPOCH) {
20893 Ok(duration) => duration.as_secs() as i64,
20894 Err(_) => return "-".to_string(),
20895 };
20896 let days = secs.div_euclid(86_400);
20897 let secs_of_day = secs.rem_euclid(86_400);
20898 let (year, month, day) = civil_from_days(days);
20899 let hour = secs_of_day / 3_600;
20900 let minute = (secs_of_day % 3_600) / 60;
20901 format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}")
20902}
20903
20904#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
20905fn civil_from_days(days: i64) -> (i32, u32, u32) {
20906 let z = days + 719_468;
20907 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
20908 let doe = z - era * 146_097;
20909 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
20910 let y = yoe + era * 400;
20911 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
20912 let mp = (5 * doy + 2) / 153;
20913 let d = doy - (153 * mp + 2) / 5 + 1;
20914 let m = mp + if mp < 10 { 3 } else { -9 };
20915 let year = y + i64::from(m <= 2);
20916 (year as i32, m as u32, d as u32)
20917}
20918
20919#[allow(clippy::case_sensitive_file_extension_comparisons)]
20922fn detect_language_name(name: &str) -> Option<&'static str> {
20923 let lower = name.to_ascii_lowercase();
20924 if lower.ends_with(".c") || lower.ends_with(".h") {
20925 Some("C")
20926 } else if [".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx"]
20927 .iter()
20928 .any(|s| lower.ends_with(s))
20929 {
20930 Some("C++")
20931 } else if lower.ends_with(".cs") {
20932 Some("C#")
20933 } else if lower.ends_with(".py") {
20934 Some("Python")
20935 } else if lower.ends_with(".sh") {
20936 Some("Shell")
20937 } else if [".ps1", ".psm1", ".psd1"]
20938 .iter()
20939 .any(|s| lower.ends_with(s))
20940 {
20941 Some("PowerShell")
20942 } else {
20943 None
20944 }
20945}
20946
20947fn language_icon_file(language: &str) -> Option<&'static str> {
20948 match language {
20949 "C" => Some("c.png"),
20950 "C++" => Some("cpp.png"),
20951 "C#" => Some("c-sharp.png"),
20952 "Python" => Some("python.png"),
20953 "Shell" => Some("shell.png"),
20954 "PowerShell" => Some("powershell.png"),
20955 "JavaScript" => Some("java-script.png"),
20956 "HTML" => Some("html-5.png"),
20957 "Java" => Some("java.png"),
20958 "Visual Basic" => Some("visual-basic.png"),
20959 "Assembly" => Some("asm.png"),
20960 "Go" => Some("go.png"),
20961 "R" => Some("r.png"),
20962 "XML" => Some("xml.png"),
20963 "Groovy" => Some("groovy.png"),
20964 "Dockerfile" => Some("docker.png"),
20965 "Makefile" => Some("makefile.svg"),
20966 "Perl" => Some("perl.svg"),
20967 _ => None,
20968 }
20969}
20970
20971fn language_inline_svg(language: &str) -> Option<&'static str> {
20976 match language {
20977 "Rust" => Some(
20978 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>"##,
20979 ),
20980 "TypeScript" => Some(
20981 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>"##,
20982 ),
20983 _ => None,
20984 }
20985}
20986
20987#[allow(clippy::case_sensitive_file_extension_comparisons)]
20990fn classify_preview_file(name: &str) -> PreviewKind {
20991 let lower = name.to_ascii_lowercase();
20992
20993 let scannable = [
20994 ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hh", ".hxx", ".cs", ".py", ".sh", ".ps1",
20995 ".psm1", ".psd1",
20996 ]
20997 .iter()
20998 .any(|suffix| lower.ends_with(suffix));
20999
21000 if scannable {
21001 PreviewKind::Supported
21002 } else if lower.ends_with(".min.js")
21003 || lower.ends_with(".lock")
21004 || lower.ends_with(".png")
21005 || lower.ends_with(".jpg")
21006 || lower.ends_with(".jpeg")
21007 || lower.ends_with(".gif")
21008 || lower.ends_with(".zip")
21009 || lower.ends_with(".pdf")
21010 || lower.ends_with(".pyc")
21011 || lower.ends_with(".xz")
21012 || lower.ends_with(".tar")
21013 || lower.ends_with(".gz")
21014 {
21015 PreviewKind::Skipped
21016 } else {
21017 PreviewKind::Unsupported
21018 }
21019}
21020
21021fn preview_relative_path(root: &Path, path: &Path) -> String {
21022 path.strip_prefix(root)
21023 .ok()
21024 .unwrap_or(path)
21025 .to_string_lossy()
21026 .replace('\\', "/")
21027 .trim_matches('/')
21028 .to_string()
21029}
21030
21031fn should_skip_preview_directory(relative: &str, exclude_patterns: &[String]) -> bool {
21032 if relative.is_empty() {
21033 return false;
21034 }
21035
21036 exclude_patterns.iter().any(|pattern| {
21037 wildcard_match(pattern, relative)
21038 || wildcard_match(pattern, &format!("{relative}/"))
21039 || wildcard_match(pattern, &format!("{relative}/placeholder"))
21040 })
21041}
21042
21043fn should_include_preview_file(
21044 relative: &str,
21045 include_patterns: &[String],
21046 exclude_patterns: &[String],
21047) -> bool {
21048 if relative.is_empty() {
21049 return true;
21050 }
21051
21052 let included = include_patterns.is_empty()
21053 || include_patterns
21054 .iter()
21055 .any(|pattern| wildcard_match(pattern, relative));
21056 let excluded = exclude_patterns
21057 .iter()
21058 .any(|pattern| wildcard_match(pattern, relative));
21059
21060 included && !excluded
21061}
21062
21063fn wildcard_match(pattern: &str, candidate: &str) -> bool {
21064 let pattern = pattern.trim().replace('\\', "/");
21065 let candidate = candidate.trim().replace('\\', "/");
21066 let p = pattern.as_bytes();
21067 let c = candidate.as_bytes();
21068 let mut pi = 0usize;
21069 let mut ci = 0usize;
21070 let mut star: Option<usize> = None;
21071 let mut star_match = 0usize;
21072
21073 while ci < c.len() {
21074 if pi < p.len() && (p[pi] == c[ci] || p[pi] == b'?') {
21075 pi += 1;
21076 ci += 1;
21077 } else if pi < p.len() && p[pi] == b'*' {
21078 while pi < p.len() && p[pi] == b'*' {
21079 pi += 1;
21080 }
21081 star = Some(pi);
21082 star_match = ci;
21083 } else if let Some(star_pi) = star {
21084 star_match += 1;
21085 ci = star_match;
21086 pi = star_pi;
21087 } else {
21088 return false;
21089 }
21090 }
21091
21092 while pi < p.len() && p[pi] == b'*' {
21093 pi += 1;
21094 }
21095
21096 pi == p.len()
21097}
21098
21099fn escape_html(value: &str) -> String {
21100 value
21101 .replace('&', "&")
21102 .replace('<', "<")
21103 .replace('>', ">")
21104 .replace('"', """)
21105 .replace('\'', "'")
21106}
21107
21108#[derive(Clone)]
21109struct SubmoduleRow {
21110 name: String,
21111 relative_path: String,
21112 files_analyzed: u64,
21113 code_lines: u64,
21114 comment_lines: u64,
21115 blank_lines: u64,
21116 total_physical_lines: u64,
21117 html_url: Option<String>,
21118}
21119
21120#[derive(Template)]
21121#[template(
21122 source = r##"
21123<!doctype html>
21124<html lang="en">
21125<head>
21126 <meta charset="utf-8">
21127 <title>OxideSLOC | tmp-sloc</title>
21128 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
21129 <link rel="stylesheet" href="/static/app.css">
21130 <script src="/static/app.js"></script>
21131 <style nonce="{{ csp_nonce }}">
21132 :root {
21133 --bg: #efe9e2;
21134 --surface: #fcfaf7;
21135 --surface-2: #f7f0e8;
21136 --surface-3: #efe3d5;
21137 --line: #dfcfbf;
21138 --line-strong: #cfb29c;
21139 --text: #2f241c;
21140 --muted: #6f6257;
21141 --muted-2: #917f71;
21142 --nav: #b85d33;
21143 --nav-2: #7a371b;
21144 --accent: #2563eb;
21145 --accent-2: #1d4ed8;
21146 --oxide: #b85d33;
21147 --oxide-2: #8f4220;
21148 --success-bg: #eaf9ee;
21149 --success-text: #1c8746;
21150 --warn-bg: #fff2d8;
21151 --warn-text: #926000;
21152 --danger-bg: #fdeaea;
21153 --danger-text: #b33b3b;
21154 --shadow: 0 12px 28px rgba(73, 45, 28, 0.08);
21155 --shadow-strong: 0 18px 34px rgba(73, 45, 28, 0.12);
21156 --radius: 14px;
21157 }
21158
21159 body.dark-theme {
21160 --bg: #1b1511;
21161 --surface: #261c17;
21162 --surface-2: #2d221d;
21163 --surface-3: #372922;
21164 --line: #524238;
21165 --line-strong: #6c5649;
21166 --text: #f5ece6;
21167 --muted: #c7b7aa;
21168 --muted-2: #aa9485;
21169 --nav: #b85d33;
21170 --nav-2: #7a371b;
21171 --accent: #6f9bff;
21172 --accent-2: #4a78ee;
21173 --oxide: #d37a4c;
21174 --oxide-2: #b35428;
21175 --success-bg: #163927;
21176 --success-text: #8fe2a8;
21177 --warn-bg: #3c2d11;
21178 --warn-text: #f3cb75;
21179 --danger-bg: #3d1f1f;
21180 --danger-text: #ff9f9f;
21181 --shadow: 0 14px 28px rgba(0,0,0,0.28);
21182 --shadow-strong: 0 22px 38px rgba(0,0,0,0.34);
21183 }
21184
21185 * { box-sizing: border-box; }
21186 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); }
21187 html { overflow-y: scroll; }
21188 body { overflow-x: clip; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
21189 .top-nav, .page, .loading { position: relative; z-index: 2; }
21190 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
21191 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
21192 .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); }
21193 .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; }
21194 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
21195 .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)); }
21196 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
21197 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
21198 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
21199 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
21200 .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; }
21201 .nav-project-pill.visible { display:inline-flex; }
21202 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
21203 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
21204 .nav-status { display: flex; align-items: center; justify-content:flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
21205 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
21206 @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; } }
21207 .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; }
21208 a.nav-pill:hover { background:rgba(255,255,255,0.18); transform:translateY(-1px); }
21209 .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; }
21210 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
21211 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
21212 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
21213 .theme-toggle .icon-sun { display:none; }
21214 body.dark-theme .theme-toggle .icon-sun { display:block; }
21215 body.dark-theme .theme-toggle .icon-moon { display:none; }
21216 .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;}
21217 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
21218 .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);}
21219 .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;}
21220 .settings-close:hover{color:var(--text);background:var(--surface-2);}
21221 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
21222 .settings-modal-body{padding:14px 16px 16px;}
21223 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
21224 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
21225 .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;}
21226 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
21227 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
21228 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
21229 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
21230 .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;}
21231 .tz-select:focus{border-color:var(--oxide);}
21232 .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; }
21233 .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;}
21234 .page { max-width: 1720px; margin: 0 auto; padding: 18px 24px 36px; width: 100%; display: flex; flex-direction: column; }
21235 @media (max-width: 1920px) { .top-nav-inner { max-width: 1500px; } .page { max-width: 1500px; } }
21236 .summary-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
21237 .workbench-strip { display:flex; align-items:stretch; gap:16px; margin-bottom: 18px; flex-wrap: nowrap; overflow: visible; }
21238 .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; }
21239 .workbench-box:hover { transform: translateY(-3px); box-shadow: 0 14px 36px rgba(77,44,20,0.18); }
21240 body.dark-theme .workbench-box { background: var(--surface); box-shadow: var(--shadow); }
21241 .wb-stats { flex: 4 1 0; display:flex; flex-direction:column; overflow: visible; min-width: 0; position: relative; z-index: 25; }
21242 .wb-stats-header { padding: 10px 24px 0; }
21243 .wb-stats-title { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); }
21244 .ws-left { display:flex; align-items:stretch; gap:12px; flex:1 1 auto; flex-wrap:wrap; padding: 14px 20px 18px; overflow: visible; }
21245 .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; }
21246 .ws-stat:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
21247 body.dark-theme .ws-stat { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
21248 .ws-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
21249 .ws-value { font-size: 13px; font-weight: 700; color: var(--text); }
21250 .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; }
21251 body.dark-theme .ws-badge { background: rgba(211,122,76,0.15); border-color: rgba(211,122,76,0.25); color: var(--oxide); }
21252 .ws-stat-analyzers { position: relative; }
21253 .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; }
21254 .ws-stat-analyzers:hover .ws-lang-tooltip { display:block; }
21255 .ws-lang-tooltip-hdr { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:0.10em; color:var(--muted-2); margin-bottom:4px; }
21256 .ws-lang-tooltip-desc { font-size:12px; color:var(--text); line-height:1.45; margin-bottom:10px; }
21257 .ws-lang-grid { display:grid; grid-template-columns:repeat(5, 1fr); gap:5px 7px; }
21258 .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; }
21259 body.dark-theme .ws-lang-item { background:rgba(211,122,76,0.12); border-color:rgba(211,122,76,0.22); color:var(--oxide); }
21260 .ws-divider { display: none; }
21261 .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%; }
21262 .ws-path-link:hover { color:var(--oxide); }
21263 body.dark-theme .ws-path-link { color:var(--oxide); }
21264 .ws-stat-output { flex:1 1 0; min-width:0; overflow:hidden; }
21265 .ws-stat-output .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
21266 .ws-stat-clamp { max-width: 200px; overflow: hidden; }
21267 .ws-stat-clamp .ws-value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block; }
21268 .ws-mini-box-sm { flex:0 0 auto; min-width:80px; max-width:110px; }
21269 .ws-mini-box-sm .ws-mini-label { font-size:9px; }
21270 .ws-mini-box-sm .ws-mini-value { font-size:13px; }
21271 .ws-mini-box-lg { flex:2 1 0; }
21272 .ws-mini-box-lg .ws-mini-value { font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
21273 .ws-mini-box-br { flex:1.5 1 0; }
21274 .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; }
21275 .scope-legend-label { font-weight:800; color:var(--text); white-space:nowrap; flex-shrink:0; margin-right:10px; }
21276 .path-scope-grid { display:grid; grid-template-columns: calc(42% - 7px) auto auto 1px 1fr; gap:0 8px; align-items:center; }
21277 #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; }
21278 .path-scope-grid > input[type=text] { width:100%; min-width:0; }
21279 .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; }
21280 .git-source-banner svg { width:15px; height:15px; stroke:#7c3aed; fill:none; stroke-width:2; flex-shrink:0; }
21281 .git-source-banner strong { font-weight:800; color:var(--text); }
21282 .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; }
21283 body.dark-theme .git-source-banner code { background:rgba(167,139,250,0.10); color:#c4b5fd; border-color:rgba(167,139,250,0.22); }
21284 .git-source-banner a { color:var(--oxide-2); font-weight:700; text-decoration:none; margin-left:auto; font-size:12px; }
21285 .git-source-banner a:hover { text-decoration:underline; }
21286 .git-locked-input { background:var(--surface-2) !important; cursor:default; color:var(--muted) !important; }
21287 .path-scope-sep { background:var(--line); margin:4px 14px; }
21288 .recent-more-link { padding:10px 16px; font-size:13px; color:var(--muted); border-top:1px solid var(--line); }
21289 .recent-more-link a { color:var(--oxide-2); text-decoration:underline; }
21290 .step3-separator { border:none; border-top:1px solid var(--line); margin:20px 0; }
21291 .ws-history-group { display:flex; flex-direction:column; justify-content:center; padding: 16px 28px; flex: 3 1 0; min-width: 0; }
21292 .ws-history-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted-2); margin-bottom: 10px; }
21293 .ws-history-inner { display:flex; align-items:center; gap: 14px; flex-wrap: nowrap; }
21294 .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; }
21295 .ws-mini-box:hover { transform: translateY(-4px); box-shadow: 0 12px 32px rgba(77,44,20,0.2); }
21296 body.dark-theme .ws-mini-box { background: rgba(211,122,76,0.08); border-color: rgba(211,122,76,0.20); }
21297 .ws-mini-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); }
21298 .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; }
21299 .wb-ftip-arrow { position:absolute; bottom:100%; left:20px; width:0; height:0; border:6px solid transparent; border-bottom-color:var(--line-strong); }
21300 .wb-ftip-arrow::after { content:''; position:absolute; top:2px; left:-5px; width:0; height:0; border:5px solid transparent; border-bottom-color:var(--surface); }
21301 [data-wb-tip] { cursor:help; }
21302 .ws-mini-value { font-size: 17px; font-weight: 800; color: var(--text); }
21303 .ws-mini-actions { display:flex; flex-direction:column; gap: 4px; margin-left: 4px; }
21304 .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; }
21305 .ws-action-link svg { width: 15px; height: 15px; flex-shrink:0; }
21306 .ws-action-link:hover { background: rgba(184,93,51,0.14); border-color: rgba(184,93,51,0.35); text-decoration:none; }
21307 body.dark-theme .ws-action-link { color: var(--oxide); border-color: rgba(211,122,76,0.25); background: rgba(211,122,76,0.08); }
21308 .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; }
21309 .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); }
21310 .card:hover, .step-nav:hover { box-shadow: var(--shadow-strong); border-color: var(--line-strong); }
21311 .side-info-card { padding: 18px; }
21312 .side-mini-list { display:grid; gap: 10px; margin-top: 14px; }
21313 .side-mini-item { color: var(--muted); font-size: 13px; line-height: 1.55; }
21314 .summary-card { padding: 18px 18px 16px; position: relative; overflow: hidden; }
21315 .summary-card::before { content:""; position:absolute; inset:0 auto 0 0; width:4px; background: linear-gradient(180deg, var(--oxide), var(--oxide-2)); }
21316 .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); }
21317 .summary-value { margin-top: 10px; font-size: 17px; font-weight: 700; color: var(--text); line-height: 1.4; }
21318 .summary-body { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
21319 .coverage-pills { display:flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
21320 .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; }
21321 .layout { display:grid; grid-template-columns: 244px minmax(0, 1fr); gap: 18px; align-items:stretch; flex: 1; min-height: 0; }
21322 .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; }
21323 .side-stack::-webkit-scrollbar { display: none; }
21324 .step-nav { padding: 20px 16px; }
21325 .step-nav h3 { margin: 6px 4px 14px; font-size: 16px; font-weight: 850; letter-spacing: -0.01em; }
21326 .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; }
21327 .step-button:hover { background: var(--surface-2); }
21328 .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); }
21329 .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; }
21330 .step-nav-info { margin:20px 4px 0; padding:14px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
21331 .step-nav-info-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:6px; }
21332 .step-nav-info-desc { font-size:12px; color:var(--muted); line-height:1.55; }
21333 .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); }
21334 .step-nav-sum-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; padding:3px 0; border-bottom:1px solid var(--line); }
21335 .step-nav-sum-row:last-child { border-bottom:none; }
21336 .step-nav-sum-key { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.07em; color:var(--muted-2); flex-shrink:0; }
21337 .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; }
21338 .step-steps-divider { height:1px; background:var(--line); margin: 12px 4px; }
21339 .quick-scan-divider { height:1px; background:var(--line); margin: 12px 4px; }
21340 .quick-scan-section { padding: 10px 4px 14px; }
21341 .quick-scan-label { font-size:10px; font-weight:900; text-transform:uppercase; letter-spacing:.08em; color:var(--muted-2); margin-bottom:16px; }
21342 .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; }
21343 .quick-scan-btn:hover { transform:translateY(-2px); box-shadow:0 10px 24px rgba(184,80,40,0.35); }
21344 .quick-scan-btn:active { transform:translateY(0); }
21345 .quick-scan-btn:disabled { opacity:.6; cursor:not-allowed; transform:none; }
21346 .quick-scan-hint { font-size:11px; color:var(--muted); margin-top:16px; line-height:1.4; text-align:center; hyphens:none; overflow-wrap:normal; }
21347 .step-button.active .step-num { background: rgba(37,99,235,0.18); color: var(--accent-2); animation: stepPulse 2.5s ease-in-out infinite; }
21348 @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);} }
21349 @keyframes stepEntrance { from{opacity:0;transform:translateX(-8px);} to{opacity:1;transform:translateX(0);} }
21350 .step-nav > button:nth-child(2) { animation-delay: 0.04s; }
21351 .step-nav > button:nth-child(3) { animation-delay: 0.09s; }
21352 .step-nav > button:nth-child(4) { animation-delay: 0.14s; }
21353 .step-nav > button:nth-child(5) { animation-delay: 0.19s; }
21354 .step-check { margin-left:auto; width:14px; height:14px; stroke:#16a34a; fill:none; opacity:0; transition:opacity 0.22s ease; flex-shrink:0; }
21355 .step-button.done .step-check { opacity:1; }
21356 .step-button.done .step-num { background:rgba(34,197,94,0.16); color:#16a34a; }
21357 .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; }
21358 .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; }
21359 .sidebar-scroll-divider { height:1px; background:var(--line); margin: 12px 4px; }
21360 .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; }
21361 .sidebar-scroll-btn:hover { background:var(--surface-3); border-color:var(--line-strong); color:var(--text); text-decoration:none; }
21362 .sidebar-scroll-btn svg { width:12px; height:12px; stroke:currentColor; fill:none; stroke-width:2.5; flex-shrink:0; }
21363 .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; }
21364 body.dark-theme .card-header { background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent), var(--surface); }
21365 .card-title-row { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
21366 .wizard-progress { min-width: 288px; max-width: 384px; width: 100%; }
21367 .wizard-progress-top { display:flex; justify-content:space-between; align-items:center; gap: 12px; margin-bottom: 8px; }
21368 .wizard-progress-label { font-size: 12px; font-weight: 800; color: var(--muted-2); text-transform: uppercase; letter-spacing: 0.08em; }
21369 .wizard-progress-value { font-size: 13px; font-weight: 900; color: var(--text); }
21370 .wizard-progress-track { width: 100%; height: 10px; border-radius: 999px; background: var(--surface-3); border: 1px solid var(--line); overflow: hidden; }
21371 .wizard-progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, var(--oxide), var(--accent)); transition: width 0.22s ease; }
21372 .card-title { margin:0; font-size: 22px; font-weight: 850; letter-spacing: -0.03em; }
21373 .card-subtitle { margin: 10px 0 0; padding-bottom: 22px; color: var(--muted); font-size: 16px; line-height: 1.65; max-width: 920px; }
21374 .card-body { padding: 22px; }
21375 .wizard-step { display:none; opacity: 0; transform: translateY(8px); }
21376 .wizard-step.active { display:block; animation: stepFade 220ms ease both; }
21377 @keyframes stepFade { from { opacity: 0; transform: translateY(12px); filter: blur(2px);} to { opacity: 1; transform: translateY(0); filter: blur(0);} }
21378 .section { margin-bottom: 12px; padding-bottom: 22px; border-bottom:1px solid var(--line); }
21379 .section:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
21380 .field-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
21381 .field-grid.three { grid-template-columns: 1fr 1fr 1fr; }
21382 .field-grid.sidebarish { grid-template-columns: 1.2fr .8fr; }
21383 .field { min-width:0; }
21384 label { display:block; margin:0 0 8px; font-size: 14px; font-weight: 800; color: var(--text); }
21385 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; }
21386 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); }
21387 input[type="text"]:hover, textarea:hover, select:hover { border-color: var(--accent); }
21388 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); }
21389 textarea { min-height: 128px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
21390 textarea.glob-textarea { font-size: 13px; padding: 10px 12px; }
21391 .glob-label-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:6px; min-height:28px; }
21392 .hint { margin-top: 8px; color: var(--muted); font-size: 13px; line-height: 1.55; }
21393 .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; }
21394 .path-history-badge.found { background: var(--info-bg, #eef3ff); color: var(--info-text, #4467d8); border: 1px solid rgba(100,130,220,0.25); }
21395 .path-history-badge.new { background: var(--success-bg, #e8f5ed); color: var(--success-text, #1a8f47); border: 1px solid rgba(30,143,71,0.2); }
21396 .path-history-badge.warning { background: #fff0f0; color: #b91c1c; border: 1px solid #fca5a5; font-weight: 700; padding: 8px 14px; border-radius: 8px; }
21397 body.dark-theme .path-history-badge.warning { background: #3a1010; color: #f87171; border-color: #7f1d1d; }
21398 .input-group { display:grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items:center; }
21399 .input-group.compact { grid-template-columns: 1fr auto auto; }
21400 .path-row-grid { display:grid; grid-template-columns: minmax(0, 0.6fr) minmax(220px, 0.4fr); gap: 18px; align-items:end; }
21401 .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)); }
21402 .path-info-card-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.10em; color: var(--muted-2); margin-bottom: 10px; }
21403 .path-info-row { display:flex; justify-content:space-between; align-items:baseline; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--line); }
21404 .path-info-row:last-child { border-bottom: none; padding-bottom: 0; }
21405 .path-info-key { font-size: 12px; color: var(--muted); font-weight: 600; }
21406 .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; }
21407 .full-output-row { display:grid; grid-template-columns: 1fr; gap: 16px; }
21408 .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; }
21409 .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); }
21410 .mini-button.oxide { color: var(--oxide-2); background: rgba(184,93,51,0.08); border-color: rgba(184,93,51,0.22); }
21411 .mini-button.primary-lite { background: rgba(37,99,235,0.08); color: var(--accent-2); border-color: rgba(37,99,235,0.20); }
21412 #browse-path { min-height: 38px; font-size: 13px; padding: 0 18px; }
21413 #use-sample-path { min-height: 38px; font-size: 13px; padding: 0 13px; }
21414 .scope-legend-badges { display:flex; flex:1; align-items:center; justify-content:space-evenly; gap:6px; min-width:0; flex-wrap:nowrap; }
21415 .scope-legend-row .badge { flex:0 0 auto; font-size: 11px; min-height: 24px; padding: 0 10px; white-space: nowrap; }
21416 @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; } }
21417 button.primary { background: linear-gradient(180deg, var(--accent), var(--accent-2)); color:#fff; border-color: transparent; }
21418 button.secondary { background: var(--surface); }
21419 button.next-step { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
21420 button.next-step:hover { opacity: 0.88; box-shadow: 0 6px 20px rgba(0,0,0,0.22); transform: translateY(-1px); }
21421 button.prev-step { color: var(--nav); border-color: var(--nav); background: var(--surface); }
21422 button.prev-step:hover { background: linear-gradient(180deg, var(--nav), var(--nav-2)); color: #fff; border-color: transparent; }
21423 .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); }
21424 .section + .wizard-actions { border-top: none; padding-top: 0; }
21425 .wizard-actions .left, .wizard-actions .right { display:flex; gap: 10px; flex-wrap:wrap; align-items:center; }
21426 .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; }
21427 .default-path-overlay.open { opacity: 1; pointer-events: auto; }
21428 .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; }
21429 .default-path-overlay.open .default-path-modal { transform: translateY(0); }
21430 .default-path-modal h3 { margin: 0 0 15px; font-size: 22px; color: var(--text); display: flex; align-items: center; gap: 12px; }
21431 .default-path-modal h3 svg { width: 26px; height: 26px; flex-shrink: 0; color: var(--accent); }
21432 .default-path-modal p { margin: 0 0 11px; font-size: 12px; line-height: 1.6; color: var(--muted); }
21433 .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); }
21434 body.dark-theme .default-path-modal p code { background: rgba(255,255,255,0.10); }
21435 .default-path-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 24px; }
21436 .default-path-actions button { font-size: 10.5px; padding: 6px 13px; border-radius: 8px; }
21437 .field-help-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
21438 .field-help-grid.coupled-help { margin-top: 12px; }
21439 .field-help-grid.preset-grid { align-items: start; }
21440 .preset-inline-row { display:grid; grid-template-columns: minmax(0, 0.55fr) 1fr; gap: 20px; align-items:start; margin-bottom: 16px; }
21441 .preset-inline-row .field { margin: 0; }
21442 .preset-inline-row .explainer-card { margin: 0; }
21443 .preset-inline-row .toggle-card { display:flex; flex-direction:column; }
21444 .preset-inline-row .explainer-card { display:flex; flex-direction:column; }
21445 .preset-kv-row { display:flex; align-items:flex-start; gap:20px; margin-bottom:16px; }
21446 .preset-kv-row > :first-child { flex:0 0 35%; min-width:0; }
21447 .preset-kv-row > :last-child { flex:1; min-width:0; }
21448 .output-field-row { display:grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items:start; }
21449 .output-field-row .field { margin: 0; }
21450 .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; }
21451 .output-field-aside strong { display:block; font-size: 13px; font-weight: 800; letter-spacing: 0.04em; color: var(--text); margin-bottom: 6px; }
21452 .step3-subtitle { margin-bottom: 10px; max-width: none; }
21453 .counting-intro { margin-bottom: 8px; max-width: none; }
21454 .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; }
21455 .counting-top-grid { gap: 20px; margin-top: 12px; align-items: start; }
21456 .counting-top-grid .field { padding: 16px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
21457 .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; }
21458 .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; }
21459 .section-spacer-top { margin-top: 28px; }
21460 .explainer-card { padding: 18px; background: linear-gradient(180deg, rgba(184,93,51,0.05), transparent), var(--surface); }
21461 .explainer-card.prominent { box-shadow: 0 0 0 1px rgba(184,93,51,0.14), var(--shadow); }
21462 .explainer-body { margin-top: 10px; color: var(--muted); font-size: 14px; line-height: 1.68; }
21463 .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); }
21464 .preset-summary-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 12px; }
21465 .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; }
21466 .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; }
21467 .glob-guidance-grid { display:grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
21468 .glob-guidance-card { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
21469 .glob-guidance-card strong { display:block; margin-bottom: 8px; color: var(--text); }
21470 .glob-guidance-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.58; }
21471 .lbl-opt { font-weight:400; font-size:12px; color:var(--muted); margin-left:4px; }
21472 .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; }
21473 .include-scope-badge.scope-all { background:rgba(42,104,70,0.1); border:1px solid rgba(42,104,70,0.25); color:#2a6846; }
21474 .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); }
21475 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; }
21476 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; }
21477 .attrib-estimate { margin-top:8px; padding:8px 11px; border-radius:8px; font-size:12px; font-weight:600; line-height:1.45; border:1px solid transparent; }
21478 .attrib-estimate.est-light { background:rgba(42,104,70,0.09); border-color:rgba(42,104,70,0.22); color:#2a6846; }
21479 .attrib-estimate.est-moderate { background:rgba(212,160,23,0.1); border-color:rgba(212,160,23,0.28); color:#8a6a10; }
21480 .attrib-estimate.est-heavy { background:rgba(178,48,48,0.09); border-color:rgba(178,48,48,0.26); color:#a33030; }
21481 .attrib-estimate b { font-weight:800; }
21482 body.dark-theme .attrib-estimate.est-light { background:rgba(90,186,138,0.12); border-color:rgba(90,186,138,0.3); color:#5aba8a; }
21483 body.dark-theme .attrib-estimate.est-moderate { background:rgba(212,160,23,0.14); border-color:rgba(212,160,23,0.34); color:#e0c060; }
21484 body.dark-theme .attrib-estimate.est-heavy { background:rgba(224,112,112,0.14); border-color:rgba(224,112,112,0.34); color:#e07070; }
21485 .review-attrib-warn { margin-top:16px; padding:13px 16px; border-radius:10px; font-size:13px; font-weight:600; line-height:1.55; display:flex; gap:10px; align-items:flex-start; }
21486 .review-attrib-warn::before { content:"\26A0"; font-size:16px; line-height:1.3; flex:0 0 auto; }
21487 .review-attrib-warn.raw-moderate { background:rgba(212,160,23,0.12); border:1px solid rgba(212,160,23,0.3); color:#8a6a10; }
21488 .review-attrib-warn.raw-heavy { background:rgba(178,48,48,0.1); border:1px solid rgba(178,48,48,0.3); color:#a33030; }
21489 .review-attrib-warn b { font-weight:800; }
21490 body.dark-theme .review-attrib-warn.raw-moderate { background:rgba(212,160,23,0.16); border-color:rgba(212,160,23,0.36); color:#e0c060; }
21491 body.dark-theme .review-attrib-warn.raw-heavy { background:rgba(224,112,112,0.16); border-color:rgba(224,112,112,0.36); color:#e07070; }
21492 .toggle-card { border:1px solid var(--line); border-radius: 12px; background: var(--surface-2); padding: 16px; }
21493 .checkbox { display:flex; align-items:flex-start; gap: 10px; font-size: 15px; font-weight:700; }
21494 .checkbox input { width: 16px; height: 16px; margin-top: 3px; accent-color: var(--accent); }
21495 .scan-rules-grid { display:grid; gap: 0; margin-top: 4px; padding-bottom: 24px; }
21496 .scan-rules-grid .preset-inline-row { margin-bottom: 0; align-items: start; padding: 22px 0; border-bottom: 1px solid var(--line); }
21497 .scan-rules-grid .preset-inline-row:first-child { padding-top: 0; }
21498 .scan-rules-grid .preset-inline-row:last-child { padding-bottom: 0; border-bottom: none; }
21499 .advanced-rule-table { display:grid; gap: 12px; margin-top: 18px; }
21500 .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); }
21501 .advanced-rule-row.static-note { grid-template-columns: 220px minmax(0, 1fr); }
21502 .toggle-card.compact { padding: 0; background: none; border: none; box-shadow: none; }
21503 .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; }
21504 .docstring-example-inset .field-help-title { margin-bottom: 6px; }
21505 .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; }
21506 .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; }
21507 .always-tracked-tip-body { flex:1; min-width:0; }
21508 .always-tracked-tip-body .field-help-title { color: var(--accent-2); }
21509 .always-tracked-tip-body h4 { margin: 2px 0 6px; font-size: 15px; }
21510 .always-tracked-tip-body .advanced-rule-description { font-size: 14px; color: var(--muted); line-height: 1.6; }
21511 .always-tracked-metrics-row { display:grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap:6px 18px; margin:8px 0 0; }
21512 .always-tracked-metrics-row > div { font-size:13px; color:var(--muted); line-height:1.5; }
21513 .always-tracked-metrics-row strong { display:block; font-size:13px; color:var(--text); margin-bottom:2px; white-space:nowrap; }
21514 @media (max-width:900px) { .always-tracked-metrics-row { grid-template-columns: repeat(2,minmax(0,1fr)); } }
21515 .advanced-rule-head h4 { margin: 6px 0 0; font-size: 16px; }
21516 .advanced-rule-description { color: var(--muted); font-size: 13px; line-height: 1.6; }
21517 .advanced-rule-description strong { color: var(--text); }
21518 .output-identity-grid { display:grid; grid-template-columns: 1.15fr 0.95fr; gap: 18px; align-items:start; margin-top: 22px; }
21519 .review-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap: 10px; margin-bottom: 8px; }
21520 .review-link { border:none; background: transparent; color: var(--accent-2); font-size: 12px; font-weight: 800; cursor: pointer; padding: 0; }
21521 .review-link:hover { text-decoration: underline; }
21522 .artifact-tags { display:flex; flex-wrap:wrap; gap: 8px; margin-top: 14px; }
21523 .review-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 18px; }
21524 .review-card { padding: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.22), transparent), var(--surface); }
21525 .review-card.highlight { background: linear-gradient(180deg, rgba(37,99,235,0.05), transparent), var(--surface); }
21526 .review-card h4 { margin: 0 0 8px; font-size: 17px; }
21527 .review-card p, .review-card li { color: var(--muted); font-size: 14px; line-height: 1.62; }
21528 .review-card ul { padding-left: 18px; margin: 0; }
21529 .review-scan-note { margin-top: 10px; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface-2); }
21530 .review-scan-note-label { font-size: 10px; font-weight: 900; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 4px; }
21531 .review-scan-note p { margin: 3px 0 0; font-size: 12px; line-height: 1.45; }
21532 .review-scan-note code { display:inline; padding: 1px 5px; border-radius: 5px; font-size: 11px; }
21533 .review-card { min-height: 0; }
21534 .scope-info-row { display:flex; gap:14px; align-items:stretch; margin:12px 0; }
21535 .scope-info-row .explorer-language-strip { flex:1; min-width:0; overflow:hidden; }
21536 .scope-info-row .preview-note { flex:0 0 52%; margin:0; font-size:12px; line-height:1.5; padding:10px 12px; }
21537 .language-pill-row.iconified { flex-wrap:nowrap; overflow:hidden; }
21538 .lang-overflow-chip { position:relative; cursor:default; }
21539 .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; }
21540 .lang-overflow-chip:hover .lang-overflow-tip { display:block; }
21541 .git-inline-row { align-items:start; }
21542 .mixed-line-card { display:flex; flex-direction:column; }
21543 .preset-inline-row .toggle-card { justify-content: center; }
21544 .explorer-wrap { display:grid; gap: 16px; margin-top: 18px; }
21545 .explorer-toolbar { display:flex; justify-content:space-between; gap: 12px; align-items:flex-start; }
21546 .explorer-toolbar.compact { padding: 0; border-bottom: none; }
21547 .explorer-title { font-size: 18px; font-weight: 850; }
21548 .explorer-subtitle { margin-top: 6px; color: var(--muted); font-size: 14px; line-height: 1.55; max-width: 520px; }
21549 .explorer-subtitle.wide { max-width: none; }
21550 .preview-legend { display:flex; flex-wrap:wrap; gap: 10px; }
21551 .better-spacing { align-items:flex-start; justify-content:flex-end; }
21552 .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; }
21553 .badge-scan { background: var(--success-bg); color: var(--success-text); border-color: #bce6c8; }
21554 .badge-skip { background: var(--warn-bg); color: var(--warn-text); border-color: #eed9a4; }
21555 .badge-unsupported { background: var(--danger-bg); color: var(--danger-text); border-color: #f1c3c3; }
21556 .badge-dir { background: #e8eeff; color: #365caa; border-color: #cad7f3; }
21557 body.dark-theme .badge-dir { background:#223058; color:#bfd0ff; border-color:#3b4f87; }
21558 .scope-stats { display:grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
21559 .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; }
21560 .scope-stat-button:hover { transform: translateY(-1px); box-shadow: var(--shadow); border-color: var(--line-strong); }
21561 .scope-stat-button.active { box-shadow: 0 0 0 2px rgba(37,99,235,0.14), var(--shadow); border-color: var(--accent); }
21562 .scope-stat-button.supported { background: var(--success-bg); }
21563 .scope-stat-button.skipped { background: var(--warn-bg); }
21564 .scope-stat-button.unsupported { background: var(--danger-bg); }
21565 .scope-stat-button.reset { background: linear-gradient(180deg, rgba(37,99,235,0.08), transparent), var(--surface); }
21566 .scope-stat-label { display:block; font-size:12px; font-weight:800; color: var(--muted-2); text-transform: uppercase; letter-spacing: .08em; }
21567 .scope-stat-value { display:block; margin-top: 6px; font-size: 22px; font-weight: 900; color: var(--text); }
21568 [data-tooltip] { position: relative; }
21569 [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); }
21570 [data-tooltip]:hover::after { display: block; }
21571 .scope-stat-button[data-tooltip] { cursor: pointer; }
21572 .badge[data-tooltip] { cursor: help; }
21573 .explorer-meta-grid { display:grid; grid-template-columns: 1.4fr 1fr; gap: 12px; }
21574 .explorer-meta-grid.split { grid-template-columns: 1.3fr .9fr; }
21575 .explorer-meta-card, .preview-note { padding: 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--surface-2); }
21576 .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; }
21577 .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; }
21578 code { display:inline-block; margin-top:0; padding:2px 7px; }
21579 .explorer-language-strip { padding: 14px; border-radius: 12px; border:1px solid var(--line); background: var(--surface-2); }
21580 .language-pill-row { display:flex; flex-wrap:wrap; gap: 10px; margin-top: 10px; }
21581 .language-pill.has-icon { display:inline-flex; align-items:center; gap: 10px; padding-right: 14px; }
21582 .language-pill.has-icon img { width: 18px; height: 18px; object-fit: contain; }
21583 .language-pill.muted-pill { color: var(--muted); }
21584 button.language-pill { appearance:none; cursor:pointer; }
21585 .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); }
21586 .file-explorer-shell { border:1px solid var(--line); border-radius: 14px; overflow:hidden; background: var(--surface); }
21587 .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; }
21588 .file-explorer-actions, .file-explorer-search-row { display:flex; gap: 10px; align-items:center; flex-wrap:nowrap; }
21589 .file-explorer-search-row { margin-left: auto; }
21590 .explorer-filter-select { min-width: 170px; width: 170px; }
21591 .explorer-search { min-width: 300px; width: 300px; }
21592 .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); }
21593 .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; }
21594 .tree-sort-button:hover { background: rgba(37,99,235,0.08); color: var(--accent-2); }
21595 .tree-sort-button.active { background: rgba(37,99,235,0.12); color: var(--accent-2); }
21596 .tree-sort-indicator { font-size: 13px; letter-spacing: 0; text-transform:none; }
21597 .file-explorer-tree { max-height: 640px; overflow:auto; }
21598 .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); }
21599 .tree-row:nth-child(odd) { background: rgba(255,255,255,0.25); }
21600 body.dark-theme .tree-row:nth-child(odd) { background: rgba(255,255,255,0.02); }
21601 .tree-row.hidden-by-filter { display:none !important; }
21602 .tree-name-cell, .tree-date-cell, .tree-type-cell, .tree-status-cell { padding: 4px 0; }
21603 .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; }
21604 .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; }
21605 .tree-toggle:hover { color: var(--text); background: var(--surface-3); }
21606 .tree-bullet { color: var(--muted-2); width: 22px; text-align:center; flex: 0 0 22px; font-size: 7px; opacity: 0.5; }
21607 .tree-node { display:inline-flex; align-items:center; min-width:0; }
21608 .tree-node-dir { color: var(--text); font-weight: 800; }
21609 .tree-node-supported { color: var(--success-text); }
21610 .tree-node-skipped { color: var(--warn-text); }
21611 .tree-node-unsupported { color: var(--danger-text); }
21612 .tree-node-more { color: var(--muted-2); font-style: italic; }
21613 .tree-date-cell, .tree-type-cell { color: var(--muted); font-size: 11px; }
21614 .tree-status-cell .badge { font-size: 10px; padding: 1px 7px; }
21615 .tree-status-cell { display:flex; justify-content:flex-start; }
21616 .preview-error { color: var(--danger-text); background: var(--danger-bg); border:1px solid #efc2c2; padding: 12px; border-radius: 12px; }
21617 .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; }
21618 .preview-warning strong { display:block; font-size: 14px; margin-bottom: 4px; }
21619 .preview-warning p { margin: 0 0 10px; }
21620 .repo-pick-row { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin-bottom: 10px; }
21621 .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; }
21622 .repo-pick:hover { background: var(--warn-text); color: var(--warn-bg); }
21623 .repo-pick-more { font-size: 12px; font-style: italic; opacity: 0.85; }
21624 .multi-repo-ack-label { display:flex; align-items:center; gap:8px; font-size: 12px; font-weight: 600; cursor: pointer; }
21625 .multi-repo-ack { width:15px; height:15px; accent-color: var(--warn-text); cursor: pointer; }
21626 .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; }
21627 .preview-loading { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:12px; background:var(--surface-2); border:1px solid var(--line); }
21628 .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; }
21629 @keyframes prevSpin { to { transform:rotate(360deg); } }
21630 .preview-gate-status { display:flex; align-items:center; gap:9px; font-size:13px; font-weight:600; color:var(--muted); margin-right:18px; }
21631 .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; }
21632 .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; }
21633 .preview-gate-info:hover { transform:scale(1.15); color:var(--nav); }
21634 .preview-gate-info svg { width:16px; height:16px; }
21635 .preview-panel-flash { animation:previewPanelFlash 1.4s ease; border-radius:12px; }
21636 @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); } }
21637 button.next-step.is-blocked { opacity:0.55; cursor:not-allowed; pointer-events:none; box-shadow:none; transform:none; }
21638 .preview-loading-text { flex:1; min-width:0; }
21639 .preview-loading-msg { font-size:13px; color:var(--text); font-weight:600; }
21640 .preview-loading-elapsed { font-size:11px; color:var(--muted); margin-top:2px; }
21641 .scope-preview-divider { height:1px; background:var(--line); opacity:0.5; margin-top:22px; margin-bottom:22px; }
21642 .cov-scan-status { border-radius:10px; font-size:12.5px; margin-top:10px; }
21643 .cov-scan-idle { display:none; }
21644 .cov-scan-inner { display:flex; align-items:flex-start; gap:9px; padding:10px 13px; }
21645 .cov-scan-icon { flex:0 0 15px; width:15px; height:15px; display:flex; align-items:center; justify-content:center; margin-top:1px; }
21646 .cov-scan-body { flex:1; min-width:0; line-height:1.4; }
21647 .cov-scan-title { font-weight:600; font-size:12.5px; }
21648 .cov-scan-sub { color:var(--muted); font-size:11.5px; margin-top:2px; }
21649 .cov-scan-actions { margin-top:7px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
21650 .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; }
21651 .cov-scan-use:hover { opacity:.75; }
21652 .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; }
21653 .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; }
21654 @keyframes cov-pulse { 0%,100%{opacity:.35} 50%{opacity:1} }
21655 .cov-scan-scanning { background:rgba(100,100,100,0.06); border:1px solid var(--line); }
21656 .cov-scan-scanning .cov-scan-title { color:var(--muted); }
21657 .cov-scan-scanning .cov-scan-icon svg { animation:cov-pulse 1.3s ease-in-out infinite; }
21658 .cov-scan-found { background:rgba(34,113,60,0.07); border:1px solid rgba(34,113,60,0.22); }
21659 .cov-scan-found .cov-scan-title,.cov-scan-found .cov-scan-use { color:#1f6b3a; }
21660 .cov-scan-found .cov-scan-use { border-color:#1f6b3a; }
21661 .cov-scan-found .cov-scan-tool { background:rgba(34,113,60,0.12); color:#1f6b3a; }
21662 body.dark-theme .cov-scan-found { background:rgba(34,113,60,0.1); border-color:rgba(90,186,138,0.25); }
21663 body.dark-theme .cov-scan-found .cov-scan-title,body.dark-theme .cov-scan-found .cov-scan-use { color:#5aba8a; }
21664 body.dark-theme .cov-scan-found .cov-scan-use { border-color:#5aba8a; }
21665 body.dark-theme .cov-scan-found .cov-scan-tool { background:rgba(90,186,138,0.12); color:#5aba8a; }
21666 .cov-scan-found .cov-scan-remove { color:#8b2020!important; border-color:#8b2020!important; }
21667 body.dark-theme .cov-scan-found .cov-scan-remove { color:#e07070!important; border-color:#e07070!important; }
21668 .cov-scan-hint { background:rgba(160,110,0,0.06); border:1px solid rgba(160,110,0,0.22); }
21669 .cov-scan-hint .cov-scan-title { color:#7a5e00; }
21670 .cov-scan-hint .cov-scan-tool { background:rgba(160,110,0,0.1); color:#7a5e00; }
21671 .cov-scan-hint .cov-scan-cmd { background:rgba(0,0,0,0.07); }
21672 body.dark-theme .cov-scan-hint { background:rgba(200,160,0,0.08); border-color:rgba(200,160,0,0.22); }
21673 body.dark-theme .cov-scan-hint .cov-scan-title { color:#d4a017; }
21674 body.dark-theme .cov-scan-hint .cov-scan-tool { background:rgba(200,160,0,0.12); color:#d4a017; }
21675 body.dark-theme .cov-scan-hint .cov-scan-cmd { background:rgba(255,255,255,0.07); }
21676 .cov-scan-none { background:rgba(100,100,100,0.05); border:1px solid var(--line); }
21677 .cov-scan-none .cov-scan-title { color:var(--muted); font-weight:500; }
21678 .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); }
21679 .loading.active { display:flex; }
21680 /* Lock page scroll while the analysis modal is open so the removed scrollbar
21681 gutter doesn't pull the centered card slightly left of true center. */
21682 body.modal-open { overflow: hidden; }
21683 .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; }
21684 /* Pulsating gradient sheen behind the modal content — replaces the old "Analysis running" pill */
21685 .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; }
21686 .loading-card.lc-pulsing::before { animation: lcCardPulse 3.6s ease-in-out infinite; }
21687 .loading-card > * { position:relative; z-index:1; }
21688 @keyframes lcCardPulse { 0%,100%{opacity:0.45;} 50%{opacity:1;} }
21689 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%); }
21690 .progress-bar { width:100%; height:9px; margin-top:0; background: var(--surface-3); border-radius:999px; overflow:hidden; margin-bottom:0; }
21691 .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; }
21692 @keyframes pulseBar { 0% { transform: translateX(-130%); } 100% { transform: translateX(330%); } }
21693 .lc-title { font-size:1.44rem;font-weight:800;margin:0 0 6px; }
21694 .lc-sub { color:var(--muted);font-size:0.9rem;margin:0 0 18px; }
21695 .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; }
21696 .lc-metrics { display:flex;gap:10px;margin-bottom:16px; }
21697 .lc-metric { background:var(--surface-2);border:1px solid var(--line);border-radius:10px;padding:10px 14px;flex:1 1 0;min-width:0; }
21698 .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; }
21699 .lc-metric-value { font-size:1rem;font-weight:800;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
21700 /* The Phase value holds long labels ("Attributing authorship", "Summarizing submodules") in a
21701 narrow card — let it wrap and shrink so the whole message stays readable instead of clipping. */
21702 #lc-phase { font-size:.8rem;line-height:1.2;white-space:normal;overflow:visible;text-overflow:clip;word-break:break-word; }
21703 .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; }
21704 .lc-steps { display:flex;align-items:center;justify-content:center;flex-wrap:wrap;gap:2px 0;margin-bottom:18px; }
21705 .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; }
21706 .lc-step.active { color:var(--oxide,#d37a4c);background:rgba(211,122,76,0.1);border-color:rgba(211,122,76,0.32); }
21707 .lc-step.done { color:var(--muted);opacity:0.55; }
21708 .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; }
21709 .lc-step.active .lc-step-num { background:var(--oxide,#d37a4c);color:#fff; }
21710 .lc-step.done .lc-step-num { background:rgba(80,180,100,0.22);color:#2d8a45; }
21711 .lc-step-arrow { color:var(--line-strong,#ccc);font-size:16px;padding:0 8px;flex:0 0 auto;line-height:1; }
21712 .lc-overall { margin-bottom:14px; }
21713 .lc-overall-head { display:flex;align-items:baseline;justify-content:space-between;margin-bottom:6px; }
21714 .lc-overall-label { font-size:10px;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.07em; }
21715 .lc-overall-pct { font-size:13px;font-weight:900;color:var(--oxide,#d37a4c);font-variant-numeric:tabular-nums; }
21716 .lc-overall-track { height:8px;border-radius:999px;background:var(--surface-2);border:1px solid var(--line);overflow:hidden; }
21717 .lc-overall-fill { height:100%;width:0%;border-radius:999px;background:linear-gradient(90deg,#d37a4c,#c45c10);transition:width .4s ease; }
21718 body.dark-theme .lc-overall-fill { background:linear-gradient(90deg,#e08a52,#d37a4c); }
21719 .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; }
21720 .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; }
21721 .lc-err strong { display:block;color:#8b1f1f;margin-bottom:4px;font-size:13px; }
21722 .lc-err p { margin:0;font-size:12px;color:var(--muted); }
21723 .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; }
21724 .lc-cancelled strong { display:block;color:var(--muted);margin-bottom:2px;font-size:13px; }
21725 .lc-actions { display:flex;gap:10px;flex-wrap:wrap;margin-top:14px; }
21726 .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; }
21727 .quick-excl-row { display:flex;flex-wrap:wrap;align-items:center;gap:5px;margin-top:6px; }
21728 .quick-excl-label { font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;white-space:nowrap;margin-right:2px; }
21729 .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; }
21730 .quick-excl-chip:hover { background:rgba(37,99,235,0.15);border-color:rgba(37,99,235,0.4); }
21731 .quick-excl-chip.active { background:rgba(37,99,235,0.18);border-color:rgba(37,99,235,0.55);opacity:0.6;cursor:default; }
21732 .quick-excl-chip-all { background:rgba(180,80,20,0.08);border-color:rgba(180,80,20,0.25);color:var(--nav,#b85d33); }
21733 .quick-excl-chip-all:hover { background:rgba(180,80,20,0.16);border-color:rgba(180,80,20,0.45); }
21734 body.dark-theme .quick-excl-chip { background:rgba(111,155,255,0.1);border-color:rgba(111,155,255,0.25); }
21735 body.dark-theme .quick-excl-chip-all { background:rgba(210,120,60,0.1);border-color:rgba(210,120,60,0.3); }
21736 .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; }
21737 .lc-cancel-btn:hover { color:#c0392b;border-color:#c0392b; }
21738 body.dark-theme .lc-cancelled { background:rgba(80,80,80,0.12);border-color:rgba(150,150,150,0.2); }
21739 .hidden { display:none !important; }
21740 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
21741 .site-footer a{color:var(--muted);}
21742 @media (max-width: 1280px) { .scope-stats, .explorer-meta-grid, .explorer-meta-grid.split { grid-template-columns: 1fr 1fr; } }
21743 @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; } }
21744 .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:13px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
21745 @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));}}
21746 .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;}
21747 .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; }
21748 .submodule-preview-label { display:flex; align-items:center; gap:8px; font-size:13px; font-weight:700; color:var(--text); white-space:nowrap; }
21749 .submodule-preview-label svg { width:15px; height:15px; stroke:var(--accent-2); fill:none; stroke-width:2; flex:0 0 auto; }
21750 .submodule-preview-chips { display:flex; flex-wrap:wrap; gap:8px; }
21751 .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; }
21752 .submodule-preview-chip:hover { background:rgba(37,99,235,0.18); }
21753 .submodule-preview-chip.active { background:rgba(37,99,235,0.22); box-shadow:0 0 0 2px rgba(37,99,235,0.35); }
21754 .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; }
21755 .submodule-chip-tooltip::after { content:''; position:absolute; top:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-top-color:var(--text); }
21756 .submodule-preview-chip:hover .submodule-chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
21757 .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; }
21758 .submodule-base-repo-btn:hover { background:rgba(77,44,20,0.18); }
21759 .path-info-row { display:flex; align-items:stretch; justify-content:flex-start; gap:0; margin-top:8px; border:1px solid var(--line); border-radius:10px; padding:0; overflow:hidden; flex-wrap:wrap; width:fit-content; max-width:100%; background:var(--surface); }
21760 .path-info-cell { display:flex; align-items:center; gap:7px; padding:8px 18px; border-left:1px solid var(--line); }
21761 .path-info-row > .path-info-cell:first-child, .path-info-row > *:first-child { border-left:none; }
21762 .path-info-cell.hidden, .commit-counts.hidden { display:none; }
21763 .pi-branch svg { width:15px; height:15px; flex:0 0 auto; opacity:.8; color:var(--muted); }
21764 .pi-branch-label { font-size:12px; font-weight:600; color:var(--muted); }
21765 .pi-branch-name { font-size:13px; font-weight:800; color:var(--oxide,#b85d33); }
21766 .commit-counts { display:flex; align-items:center; gap:20px; margin-left:0; padding:8px 18px; border-left:1px solid var(--line); }
21767 .commit-counts .cc-item { display:flex; flex-direction:column; line-height:1.15; }
21768 .commit-counts .cc-val { font-size:13px; font-weight:800; color:var(--oxide,#b85d33); font-variant-numeric:tabular-nums; }
21769 .commit-counts .cc-label { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.05em; color:var(--muted); white-space:nowrap; }
21770 .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; }
21771 .info-icon-btn svg { width:14px; height:14px; flex:0 0 auto; opacity:.75; }
21772 .info-icon-btn:hover { color:var(--text); }
21773 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); }
21774 body.dark-theme .submodule-preview-chip { background:rgba(37,99,235,0.18); border-color:rgba(111,155,255,0.3); }
21775 body.dark-theme .submodule-base-repo-btn { background:rgba(255,255,255,0.07); border-color:rgba(255,255,255,0.18); }
21776 .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;}
21777 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
21778 .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;}
21779 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
21780 #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);}
21781 #offline-file-banner.show{display:flex;}
21782 #offline-file-banner svg{flex-shrink:0;width:20px;height:20px;stroke:#f0b429;fill:none;stroke-width:2;}
21783 #offline-file-banner .ofb-text{flex:1;}
21784 #offline-file-banner .ofb-text a{color:#b35c00;font-weight:700;text-decoration:underline;}
21785 #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;}
21786 #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;}
21787 #offline-file-banner .ofb-dismiss:hover{background:#feefc3;}
21788 body.dark-theme #offline-file-banner{background:#2d2200;border-bottom-color:#c98a00;color:#e8c96a;}
21789 body.dark-theme #offline-file-banner svg{stroke:#c98a00;}
21790 body.dark-theme #offline-file-banner .ofb-text a{color:#f0c040;}
21791 body.dark-theme #offline-file-banner .ofb-code{background:rgba(255,255,255,0.08);}
21792 body.dark-theme #offline-file-banner .ofb-dismiss{border-color:#9a6a00;color:#e8c96a;}
21793 body.dark-theme #offline-file-banner .ofb-dismiss:hover{background:rgba(240,180,0,0.12);}
21794 </style>
21795</head>
21796<body id="page-top">
21797 <div id="offline-file-banner" role="alert">
21798 <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>
21799 <span class="ofb-text">
21800 Charts, images, and navigation require the oxide-sloc server.
21801 Start it with <span class="ofb-code">cargo run -p oxide-sloc</span> or <span class="ofb-code">bash run.sh</span>,
21802 then open this run at <a href="http://127.0.0.1:4317" target="_blank" rel="noopener">http://127.0.0.1:4317</a>.
21803 The metric tables below are fully readable without the server.
21804 </span>
21805 <button class="ofb-dismiss" id="ofb-dismiss-btn" type="button">Dismiss</button>
21806 </div>
21807 <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>
21808 <div class="background-watermarks" aria-hidden="true">
21809 <img src="/images/logo/logo-text.png" alt="" />
21810 <img src="/images/logo/logo-text.png" alt="" />
21811 <img src="/images/logo/logo-text.png" alt="" />
21812 <img src="/images/logo/logo-text.png" alt="" />
21813 <img src="/images/logo/logo-text.png" alt="" />
21814 <img src="/images/logo/logo-text.png" alt="" />
21815 <img src="/images/logo/logo-text.png" alt="" />
21816 <img src="/images/logo/logo-text.png" alt="" />
21817 <img src="/images/logo/logo-text.png" alt="" />
21818 <img src="/images/logo/logo-text.png" alt="" />
21819 <img src="/images/logo/logo-text.png" alt="" />
21820 <img src="/images/logo/logo-text.png" alt="" />
21821 <img src="/images/logo/logo-text.png" alt="" />
21822 <img src="/images/logo/logo-text.png" alt="" />
21823 </div>
21824 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
21825 <div class="top-nav">
21826 <div class="top-nav-inner">
21827 <a class="brand" href="/">
21828 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
21829 <div class="brand-copy">
21830 <div class="brand-title">OxideSLOC</div>
21831 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
21832 </div>
21833 </a>
21834 <div class="nav-project-slot">
21835 <div class="nav-project-pill" id="nav-project-pill" aria-live="polite">
21836 <span class="nav-project-label">Project</span>
21837 <span class="nav-project-value" id="nav-project-title">tmp-sloc</span>
21838 </div>
21839 </div>
21840 <div class="nav-status">
21841 <a class="nav-pill" href="/">Home</a>
21842 <div class="nav-dropdown">
21843 <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>
21844 <div class="nav-dropdown-menu">
21845 <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>
21846 </div>
21847 </div>
21848 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
21849 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
21850 <div class="nav-dropdown">
21851 <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>
21852 <div class="nav-dropdown-menu">
21853 <a href="/code-ownership"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>Code Ownership</a>
21854 <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>
21855 </div>
21856 </div>
21857 <div class="server-status-wrap" id="server-status-wrap">
21858 <div class="nav-pill server-online-pill" id="server-status-pill">
21859 <span class="status-dot" id="status-dot"></span>
21860 <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
21861 <span class="sx-d60f2ef3" id="server-ping-ms" ></span>
21862 </div>
21863 <div class="server-status-tip">
21864 {% if server_mode %}
21865 OxideSLOC is running in server mode — accessible on your LAN.
21866 {% else %}
21867 OxideSLOC is running locally — only accessible from this machine.
21868 {% endif %}
21869 <span class="sx-238af6bc" id="server-tip-ping" ></span>
21870 </div>
21871 </div>
21872 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
21873 <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>
21874 </button>
21875 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
21876 <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>
21877 <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>
21878 </button>
21879 </div>
21880 </div>
21881 </div>
21882
21883 <div class="loading" id="loading">
21884 <div class="loading-card" id="loading-card">
21885 <h2 class="lc-title" id="lc-title">Analyzing your project…</h2>
21886 <p class="lc-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
21887 <div class="lc-path" id="lc-path"><svg class="sx-9e6dfe63" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" ><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>
21888 <div class="lc-steps" id="lc-steps">
21889 <div class="lc-step active" id="lc-step-1"><span class="lc-step-num">1</span>Discover</div>
21890 <div class="lc-step-arrow">›</div>
21891 <div class="lc-step" id="lc-step-2"><span class="lc-step-num">2</span>Analyze</div>
21892 <div class="lc-step-arrow">›</div>
21893 <div class="lc-step" id="lc-step-3"><span class="lc-step-num">3</span>Attribute</div>
21894 <div class="lc-step-arrow">›</div>
21895 <div class="lc-step" id="lc-step-4"><span class="lc-step-num">4</span>Report</div>
21896 <div class="lc-step-arrow">›</div>
21897 <div class="lc-step" id="lc-step-5"><span class="lc-step-num">5</span>Done</div>
21898 </div>
21899 <div class="lc-stage-desc" id="lc-stage-desc">Initializing language analyzers and loading configuration…</div>
21900 <div class="lc-metrics" id="lc-metrics">
21901 <div class="lc-metric"><div class="lc-metric-label">Elapsed</div><div class="lc-metric-value" id="lc-elapsed">0s</div></div>
21902 <div class="lc-metric"><div class="lc-metric-label">Phase</div><div class="lc-metric-value" id="lc-phase">Starting</div></div>
21903 <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>
21904 <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>
21905 </div>
21906 <div class="lc-overall" id="lc-overall">
21907 <div class="lc-overall-head">
21908 <span class="lc-overall-label">Overall progress</span>
21909 <span class="lc-overall-pct" id="lc-overall-pct">0%</span>
21910 </div>
21911 <div class="lc-overall-track"><div class="lc-overall-fill" id="lc-overall-fill"></div></div>
21912 </div>
21913 <div class="progress-bar" id="lc-progress-bar"><span></span></div>
21914 <div class="lc-warn hidden" id="lc-warn">This is taking longer than usual. Large repositories — especially with submodules and per-author attribution on — can take several minutes. The analysis is still running.</div>
21915 <div class="lc-warn hidden" id="lc-attrib-note">Attributing authorship: running <code>git blame</code> on every source file across the repo and all submodules. This is the slowest stage and scales with file count — the counter above shows live progress. To skip it on future scans, set Code ownership to "Off" in the scan options.</div>
21916 <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>
21917 <div class="lc-cancelled hidden" id="lc-cancelled"><strong>Scan cancelled</strong></div>
21918 <div class="lc-actions hidden" id="lc-actions">
21919 <button class="primary" id="lc-dismiss" type="button">Try Again</button>
21920 <a href="/view-reports" class="lc-outline-btn">View Reports</a>
21921 </div>
21922 <button class="lc-cancel-btn" id="lc-cancel-btn" type="button">
21923 <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>
21924 Cancel scan
21925 </button>
21926 </div>
21927 </div>
21928
21929 <div class="page">
21930 <div class="workbench-strip">
21931 <div class="workbench-box wb-stats">
21932 <div class="wb-stats-header" data-wb-tip="Summarizes this session: active language analyzers, server mode, selected project, and output destination.">
21933 <span class="wb-stats-title">Analysis session</span>
21934 </div>
21935 <div class="ws-left">
21936 <div class="ws-stat ws-stat-analyzers">
21937 <span class="ws-label">Analyzers</span>
21938 <span class="ws-value">
21939 <span class="ws-badge">60 languages</span>
21940 </span>
21941 <div class="ws-lang-tooltip">
21942 <div class="ws-lang-tooltip-hdr">60 supported languages</div>
21943 <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>
21944 <div class="ws-lang-grid">
21945 <span class="ws-lang-item">Assembly</span>
21946 <span class="ws-lang-item">C</span>
21947 <span class="ws-lang-item">C++</span>
21948 <span class="ws-lang-item">C#</span>
21949 <span class="ws-lang-item">Clojure</span>
21950 <span class="ws-lang-item">CSS</span>
21951 <span class="ws-lang-item">Dart</span>
21952 <span class="ws-lang-item">Dockerfile</span>
21953 <span class="ws-lang-item">Elixir</span>
21954 <span class="ws-lang-item">Erlang</span>
21955 <span class="ws-lang-item">F#</span>
21956 <span class="ws-lang-item">Go</span>
21957 <span class="ws-lang-item">Groovy</span>
21958 <span class="ws-lang-item">Haskell</span>
21959 <span class="ws-lang-item">HTML</span>
21960 <span class="ws-lang-item">Java</span>
21961 <span class="ws-lang-item">JavaScript</span>
21962 <span class="ws-lang-item">Julia</span>
21963 <span class="ws-lang-item">Kotlin</span>
21964 <span class="ws-lang-item">Lua</span>
21965 <span class="ws-lang-item">Makefile</span>
21966 <span class="ws-lang-item">Nim</span>
21967 <span class="ws-lang-item">Obj-C</span>
21968 <span class="ws-lang-item">OCaml</span>
21969 <span class="ws-lang-item">Perl</span>
21970 <span class="ws-lang-item">PHP</span>
21971 <span class="ws-lang-item">PowerShell</span>
21972 <span class="ws-lang-item">Python</span>
21973 <span class="ws-lang-item">R</span>
21974 <span class="ws-lang-item">Ruby</span>
21975 <span class="ws-lang-item">Rust</span>
21976 <span class="ws-lang-item">Scala</span>
21977 <span class="ws-lang-item">SCSS</span>
21978 <span class="ws-lang-item">Shell</span>
21979 <span class="ws-lang-item">SQL</span>
21980 <span class="ws-lang-item">Svelte</span>
21981 <span class="ws-lang-item">Swift</span>
21982 <span class="ws-lang-item">TypeScript</span>
21983 <span class="ws-lang-item">Vue</span>
21984 <span class="ws-lang-item">XML</span>
21985 <span class="ws-lang-item">Zig</span>
21986 <span class="ws-lang-item">Solidity</span>
21987 <span class="ws-lang-item">Protobuf</span>
21988 <span class="ws-lang-item">HCL</span>
21989 <span class="ws-lang-item">GraphQL</span>
21990 <span class="ws-lang-item">Ada</span>
21991 <span class="ws-lang-item">VHDL</span>
21992 <span class="ws-lang-item">Verilog</span>
21993 <span class="ws-lang-item">Tcl</span>
21994 <span class="ws-lang-item">Pascal</span>
21995 <span class="ws-lang-item">Visual Basic</span>
21996 <span class="ws-lang-item">Lisp</span>
21997 <span class="ws-lang-item">Fortran</span>
21998 <span class="ws-lang-item">Nix</span>
21999 <span class="ws-lang-item">Crystal</span>
22000 <span class="ws-lang-item">D</span>
22001 <span class="ws-lang-item">GLSL</span>
22002 <span class="ws-lang-item">CMake</span>
22003 <span class="ws-lang-item">Elm</span>
22004 <span class="ws-lang-item">Awk</span>
22005 </div>
22006 </div>
22007 </div>
22008 <div class="ws-divider"></div>
22009 <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>
22010 <div class="ws-divider"></div>
22011 <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.">
22012 <span class="ws-label">Output</span>
22013 <span class="ws-value">
22014 <button type="button" class="ws-path-link open-folder-button" id="ws-output-link" data-folder="" title="Click to open in file explorer">
22015 <span id="ws-output-root">project/sloc</span>
22016 </button>
22017 </span>
22018 </div>
22019 </div>
22020 </div>
22021 <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.">
22022 <div class="ws-history-label">Scan history</div>
22023 <div class="ws-history-inner">
22024 <div class="ws-mini-box ws-mini-box-sm" data-wb-tip="Total completed scan runs recorded for this project since the server started.">
22025 <div class="ws-mini-label">Scans</div>
22026 <div class="ws-mini-value" id="ws-scan-count">—</div>
22027 </div>
22028 <div class="ws-mini-box ws-mini-box-lg" data-wb-tip="Timestamp of the most recently completed scan for this project.">
22029 <div class="ws-mini-label">Last Scan</div>
22030 <div class="ws-mini-value" id="ws-last-scan">—</div>
22031 </div>
22032 <div class="ws-mini-box ws-mini-box-br" data-wb-tip="Git branch name recorded during the most recent scan of this project.">
22033 <div class="ws-mini-label">Branch</div>
22034 <div class="ws-mini-value" id="ws-branch">—</div>
22035 </div>
22036 </div>
22037 </div>
22038 </div>
22039
22040 <div class="layout">
22041 <aside class="side-stack">
22042 <section class="step-nav">
22043 <h3>Guided scan setup</h3>
22044 <a href="#page-top" class="sidebar-scroll-btn" aria-label="Scroll to top of page">
22045 <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="18 15 12 9 6 15"></polyline></svg>
22046 Top of page
22047 </a>
22048 <button type="button" class="step-button active sx-726e8b31" 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>
22049 <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>
22050 <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>
22051 <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>
22052
22053 <div class="step-steps-divider"></div>
22054
22055 <div class="step-nav-info" id="step-nav-info">
22056 <div class="step-nav-info-label" id="step-nav-info-label">Step 1 of 4</div>
22057 <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>
22058 </div>
22059
22060 <div class="step-nav-summary sx-6aa34d74" id="sidebar-summary" >
22061 <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>
22062 <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>
22063 <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>
22064 </div>
22065
22066 <div class="quick-scan-divider"></div>
22067 <div class="quick-scan-section">
22068 <div class="quick-scan-label">No customization needed?</div>
22069 <button type="button" id="quick-scan-btn" class="quick-scan-btn">
22070 <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>
22071 Quick Scan
22072 </button>
22073 <div class="quick-scan-hint">Scan immediately with default settings — skips steps 2-4.</div>
22074 </div>
22075
22076 <div class="sidebar-kbd-hint"><span class="sidebar-kbd-key">←</span><span>Back</span><span class="sx-2eebea0e" >·</span><span class="sidebar-kbd-key">→</span><span>Next</span></div>
22077 <div class="sidebar-scroll-divider"></div>
22078 <a href="#page-bottom" class="sidebar-scroll-btn" aria-label="Skip to bottom of page">
22079 <svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>
22080 Skip to bottom
22081 </a>
22082 </section>
22083
22084 </aside>
22085
22086 <section class="card">
22087 <div class="card-header">
22088 <div class="card-title-row">
22089 <div>
22090 <h1 class="card-title">Guided scan configuration</h1>
22091 <p class="card-subtitle">Split setup into steps so each group of options has room for examples, explanations, and stronger customization.</p>
22092 </div>
22093 <div class="wizard-progress" aria-label="Scan setup progress">
22094 <div class="wizard-progress-top">
22095 <span class="wizard-progress-label">Setup progress</span>
22096 <span class="wizard-progress-value" id="wizard-progress-value">0%</span>
22097 </div>
22098 <div class="wizard-progress-track">
22099 <div class="wizard-progress-fill" id="wizard-progress-fill"></div>
22100 </div>
22101 </div>
22102 </div>
22103 </div>
22104 <div class="card-body">
22105 <form method="post" action="/analyze" id="analyze-form">
22106 <div class="wizard-step active" data-step="1">
22107 <div class="section">
22108 <div class="section-kicker">Step 1</div>
22109 <h2>Select project and preview scope</h2>
22110 <p class="card-subtitle">Choose the target folder, apply include and exclude filters, and preview what the current build is likely to scan.</p>
22111 <div class="field">
22112 <label for="path">Project path</label>
22113 {% if !git_repo.is_empty() %}
22114 <div class="git-source-banner">
22115 <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>
22116 Scanning from Git Browser: <strong>{{ git_repo }}</strong> at ref <code>{{ git_ref }}</code>
22117 <a href="/git-browser">← Back to Git Browser</a>
22118 </div>
22119 {% endif %}
22120 <div class="path-scope-grid">
22121 {% if !git_repo.is_empty() %}
22122 <input id="path" name="path" type="text" value="{{ git_repo }} @ {{ git_ref }}" readonly class="git-locked-input sx-14aa50c7" required />
22123 <input type="hidden" name="git_repo" value="{{ git_repo }}" />
22124 <input type="hidden" name="git_ref" value="{{ git_ref }}" />
22125 {% else %}
22126 <input id="path" name="path" type="text" value="testing/fixtures/basic" placeholder="/path/to/repository" required />
22127 <button type="button" class="mini-button oxide" id="browse-path">{% if server_mode %}Upload{% else %}Browse{% endif %}</button>
22128 <button type="button" class="mini-button" id="use-sample-path">Use sample</button>
22129 {% endif %}
22130 <div class="path-scope-sep"></div>
22131 <div class="scope-legend-row">
22132 <span class="scope-legend-label">Scope legend:</span>
22133 <span class="scope-legend-badges">
22134 <span class="badge badge-scan" data-tooltip="Files with a supported language analyzer — counted in SLOC totals.">supported</span>
22135 <span class="badge badge-skip" data-tooltip="Files excluded by a policy rule such as vendor, generated, or minified detection.">skipped by policy</span>
22136 <span class="badge badge-unsupported" data-tooltip="Files outside the supported language set — listed but not counted.">unsupported</span>
22137 </span>
22138 </div>
22139 </div>
22140 {% if git_repo.is_empty() %}
22141 {% if server_mode %}
22142 <div id="upload-limit-tip" class="hint sx-df83faee" >
22143 ℹ️ Files are compressed and streamed — no fixed size limit.
22144 </div>
22145 {% endif %}
22146 <div class="path-info-row" id="path-info-row">
22147 <div class="path-info-cell">
22148 <button type="button" class="info-icon-btn" id="project-size-btn" title="Total disk size of the selected project directory">
22149 <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>
22150 <span id="project-size-text">Project size: —</span>
22151 </button>
22152 </div>
22153 <div class="path-info-cell pi-branch hidden" id="git-branch-box" title="Currently checked-out git branch">
22154 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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>
22155 <span class="pi-branch-label">Git Branch:</span>
22156 <span class="pi-branch-name" id="git-branch-name">—</span>
22157 </div>
22158 <div class="commit-counts hidden" id="commit-counts" title="Git commit history depth (HEAD)">
22159 <div class="cc-item">
22160 <span class="cc-val" id="cc-super">—</span>
22161 <span class="cc-label">super-repo commits</span>
22162 </div>
22163 <div class="cc-item" id="cc-combined-item">
22164 <span class="cc-val" id="cc-combined">—</span>
22165 <span class="cc-label">with submodules</span>
22166 </div>
22167 </div>
22168 </div>
22169 {% else %}
22170 <div class="hint">The source code will be checked out from the remote repository at the specified ref when you run the scan.</div>
22171 {% endif %}
22172 <div id="path-history-badge" class="path-history-badge sx-6aa34d74" ></div>
22173 <div id="zero-files-warning" class="path-history-badge warning sx-6aa34d74" role="alert"></div>
22174 </div>
22175
22176 <div class="scope-preview-divider" aria-hidden="true"></div>
22177
22178 <div id="preview-panel">
22179 <div class="preview-error">Loading preview...</div>
22180 </div>
22181 </div>
22182
22183 <div class="section sx-8d842990" >
22184 <div class="preset-inline-row git-inline-row">
22185 <div class="toggle-card sx-38965f9b" >
22186 <div class="field-help-title sx-fffdd52c" >Git integration</div>
22187 <h4 class="sx-58629c9c" >Submodule breakdown</h4>
22188 <label class="checkbox">
22189 <input type="checkbox" name="submodule_breakdown" value="enabled" id="submodule_breakdown" checked />
22190 <div>
22191 <span>Detect and separate git submodules</span>
22192 <div class="hint sx-36e81f86" >Reads <code>.gitmodules</code> and produces a per-submodule breakdown alongside the overall totals.</div>
22193 </div>
22194 </label>
22195 </div>
22196 <div class="explainer-card prominent sx-38965f9b" >
22197 <div class="field-help-title sx-c500155b" >What this does</div>
22198 <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.<br /><strong>Heads up:</strong> submodule working trees are scanned in full, so a repo with many large submodules (e.g. vendored dependencies) can hold tens of thousands of files. That makes the scan — and the per-author attribution pass in particular — take noticeably longer. The scan screen shows live progress for each stage.</div>
22199 <div class="code-sample sx-726e8b31" >[submodule "libs/core"]
22200 path = libs/core
22201 url = https://github.com/org/core.git
22202
22203[submodule "libs/ui"]
22204 path = libs/ui
22205 url = https://github.com/org/ui.git</div>
22206 </div>
22207 </div>
22208 </div>
22209
22210 <div class="section">
22211 <div class="field-grid">
22212 <div class="field">
22213 <div class="glob-label-row">
22214 <label class="sx-e34c4670" for="include_globs" >Include globs <span class="lbl-opt">— optional</span></label>
22215 <div id="include-scope-badge" class="include-scope-badge scope-all sx-bacc46ba" aria-live="polite" ><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>
22216 </div>
22217 <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>
22218 <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>
22219 </div>
22220 <div class="field">
22221 <div class="glob-label-row">
22222 <label class="sx-e34c4670" for="exclude_globs" >Exclude globs</label>
22223 </div>
22224 <textarea id="exclude_globs" name="exclude_globs" class="glob-textarea" placeholder="examples: vendor/** **/*.min.js"></textarea>
22225 <div id="quick-exclude-chips" class="quick-excl-row">
22226 <span class="quick-excl-label">Quick add:</span>
22227 <button type="button" class="quick-excl-chip" data-pattern="third_party/**">third_party/**</button>
22228 <button type="button" class="quick-excl-chip" data-pattern="vendor/**">vendor/**</button>
22229 <button type="button" class="quick-excl-chip" data-pattern="node_modules/**">node_modules/**</button>
22230 <button type="button" class="quick-excl-chip" data-pattern="build/**">build/**</button>
22231 <button type="button" class="quick-excl-chip" data-pattern="target/**">target/**</button>
22232 <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>
22233 </div>
22234 <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>
22235 </div>
22236 </div>
22237 <div class="glob-guidance-grid">
22238 <div class="glob-guidance-card">
22239 <strong>How to read them</strong>
22240 <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>
22241 </div>
22242 <div class="glob-guidance-card">
22243 <strong>Common include examples</strong>
22244 <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>
22245 </div>
22246 <div class="glob-guidance-card">
22247 <strong>Common exclude examples</strong>
22248 <p><code>vendor/**</code> third-party code, <code>target/**</code> build output, <code>**/*.min.js</code> minified assets, <code>**/generated/**</code> generated files.</p>
22249 </div>
22250 </div>
22251 </div>
22252
22253 <div class="section sx-8d842990" >
22254 <div class="preset-inline-row git-inline-row">
22255 <div class="toggle-card sx-38965f9b" >
22256 <div class="field-help-title sx-fffdd52c" >Coverage</div>
22257 <h4 class="sx-58629c9c" >Code Coverage file <span class="sx-76948ec7" >(optional)</span></h4>
22258 <div class="field sx-38965f9b" >
22259 <div class="input-group compact">
22260 <input type="text" id="coverage_file" name="coverage_file" placeholder="e.g. coverage/lcov.info, coverage.xml" />
22261 <button type="button" class="mini-button oxide" id="browse-coverage">Browse</button>
22262 </div>
22263 <div class="hint sx-a33b8fc2" >When provided, line, function, and branch coverage percentages are overlaid on each file in the report and shown on the Test Metrics page.</div>
22264 <div id="cov-scan-status" class="cov-scan-status cov-scan-idle" aria-live="polite"></div>
22265 </div>
22266 </div>
22267 <div class="explainer-card prominent sx-38965f9b" >
22268 <div class="field-help-title sx-c500155b" >What this does</div>
22269 <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>
22270 <div class="code-sample sx-161ac0cc" ># C / C++ — gcov + lcov (LCOV)
22271lcov --capture --directory . --output-file coverage/lcov.info
22272
22273# C / C++ — llvm-cov (LCOV)
22274llvm-profdata merge -sparse default.profraw -o default.profdata
22275llvm-cov export -format=lcov -instr-profile=default.profdata ./mybinary > coverage/lcov.info
22276
22277# C# — coverlet (Cobertura XML)
22278dotnet test --collect:"XPlat Code Coverage"
22279
22280# Python — pytest-cov (Cobertura XML)
22281pytest --cov --cov-report=xml
22282
22283# Python — coverage.py native JSON
22284coverage run -m pytest && coverage json # writes coverage.json
22285
22286# Java / Kotlin — Gradle + JaCoCo (JaCoCo XML)
22287./gradlew jacocoTestReport</div>
22288 </div>
22289 </div>
22290 </div>
22291
22292 <div class="wizard-actions">
22293 <div class="left"></div>
22294 <div class="right">
22295 <div id="preview-gate-status" class="preview-gate-status sx-d0466aa3" aria-live="polite" >
22296 <span class="preview-gate-spinner" aria-hidden="true"></span>
22297 <span class="preview-gate-text">Scanning project scope…</span>
22298 <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">
22299 <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>
22300 </button>
22301 </div>
22302 <button type="button" class="secondary next-step" id="step1-next" data-next="2">Next: Counting rules</button>
22303 </div>
22304 </div>
22305 </div>
22306
22307 <div class="default-path-overlay" id="default-path-overlay" role="dialog" aria-modal="true" aria-labelledby="default-path-title">
22308 <div class="default-path-modal">
22309 <h3 id="default-path-title">
22310 <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>
22311 Proceed with the default sample test?
22312 </h3>
22313 <p>The <strong>Project path</strong> is still set to the bundled sample <code>testing/fixtures/basic</code></p>
22314 <p>You haven't selected your own project yet.</p>
22315 <p>Make sure to fill out the <strong>Project path</strong> with your repository and confirm it uploads successfully before scanning.</p>
22316 <div class="default-path-actions">
22317 <button type="button" class="secondary prev-step" id="default-path-cancel">Fill in project path</button>
22318 <button type="button" class="secondary next-step" id="default-path-proceed">Proceed with sample</button>
22319 </div>
22320 </div>
22321 </div>
22322
22323 <div class="wizard-step" data-step="2">
22324 <div class="section">
22325 <div class="section-kicker">Step 2</div>
22326 <h2>Choose counting behavior</h2>
22327 <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>
22328<div class="subsection-bar">Primary line classification</div>
22329 <div class="preset-kv-row">
22330 <div class="toggle-card mixed-line-card sx-38965f9b" >
22331 <div class="field-help-title sx-fffdd52c" >Primary line classification</div>
22332 <h4 class="sx-58629c9c" >Mixed-line policy</h4>
22333 <select id="mixed_line_policy" name="mixed_line_policy">
22334 <option value="code_only">Code only</option>
22335 <option value="code_and_comment">Code and comment</option>
22336 <option value="comment_only">Comment only</option>
22337 <option value="separate_mixed_category">Separate mixed category</option>
22338 </select>
22339 <div class="hint">Mixed lines share executable code and an inline comment on the same line.</div>
22340 </div>
22341 <div class="explainer-card prominent sx-38965f9b" >
22342 <div class="field-help-title" id="mixed-policy-label">Mixed-line policy explanation</div>
22343 <div class="explainer-body" id="mixed-policy-description"></div>
22344 <div class="code-sample" id="mixed-policy-example"></div>
22345 </div>
22346 </div>
22347 </div>
22348
22349 <div class="subsection-bar">Additional scan rules</div>
22350 <div class="scan-rules-grid">
22351 <div class="preset-inline-row">
22352 <div class="toggle-card sx-38965f9b" >
22353 <div class="field-help-title">Generated files</div>
22354 <h4 class="sx-0ace2b19" >Generated-file detection</h4>
22355 <select name="generated_file_detection" id="generated_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
22356 </div>
22357 <div class="explainer-card prominent sx-38965f9b" >
22358 <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>
22359 <div class="code-sample sx-161ac0cc" ># generated_file_detection = "enabled"
22360# Files matching codegen patterns are excluded:
22361# *.generated.cs *.pb.go *.g.dart</div>
22362 </div>
22363 </div>
22364 <div class="preset-inline-row">
22365 <div class="toggle-card sx-38965f9b" >
22366 <div class="field-help-title">Minified files</div>
22367 <h4 class="sx-0ace2b19" >Minified-file detection</h4>
22368 <select name="minified_file_detection" id="minified_file_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
22369 </div>
22370 <div class="explainer-card prominent sx-38965f9b" >
22371 <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>
22372 <div class="code-sample sx-161ac0cc" ># minified_file_detection = "enabled"
22373# Heuristic: very long lines + low whitespace ratio
22374# jquery.min.js bundle.min.css → skipped</div>
22375 </div>
22376 </div>
22377 <div class="preset-inline-row">
22378 <div class="toggle-card sx-38965f9b" >
22379 <div class="field-help-title">Vendor directories</div>
22380 <h4 class="sx-0ace2b19" >Vendor-directory detection</h4>
22381 <select name="vendor_directory_detection" id="vendor_directory_detection"><option value="enabled" selected>Enabled</option><option value="disabled">Disabled</option></select>
22382 </div>
22383 <div class="explainer-card prominent sx-38965f9b" >
22384 <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>
22385 <div class="code-sample sx-161ac0cc" ># vendor_directory_detection = "enabled"
22386# Directories named vendor/ node_modules/ third_party/
22387# → entire subtree is excluded from totals</div>
22388 </div>
22389 </div>
22390 <div class="preset-inline-row">
22391 <div class="toggle-card sx-38965f9b" >
22392 <div class="field-help-title">Lockfiles and manifests</div>
22393 <h4 class="sx-0ace2b19" >Include lockfiles</h4>
22394 <select name="include_lockfiles" id="include_lockfiles"><option value="disabled" selected>Disabled</option><option value="enabled">Enabled</option></select>
22395 </div>
22396 <div class="explainer-card prominent sx-38965f9b" >
22397 <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>
22398 <div class="code-sample sx-161ac0cc" ># include_lockfiles = false (default)
22399# Files like package-lock.json Cargo.lock yarn.lock
22400# → skipped unless this is enabled</div>
22401 </div>
22402 </div>
22403 <div class="preset-inline-row">
22404 <div class="toggle-card sx-38965f9b" >
22405 <div class="field-help-title">Binary handling</div>
22406 <h4 class="sx-0ace2b19" >Binary file behavior</h4>
22407 <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>
22408 </div>
22409 <div class="explainer-card prominent sx-38965f9b" >
22410 <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>
22411 <div class="code-sample sx-161ac0cc" ># binary_file_behavior = "skip" (default)
22412# Detected via long lines + low whitespace heuristic
22413# .png .exe .so → skipped silently</div>
22414 </div>
22415 </div>
22416 <div class="preset-inline-row python-docstring-wrap" id="python-docstring-wrap">
22417 <div class="toggle-card sx-38965f9b" >
22418 <div class="field-help-title">Python docstrings</div>
22419 <h4 class="sx-0ace2b19" >Docstring counting</h4>
22420 <label class="checkbox">
22421 <input id="python_docstrings_as_comments" name="python_docstrings_as_comments" type="checkbox" checked />
22422 <span>Count as comment-style lines</span>
22423 </label>
22424 </div>
22425 <div class="explainer-card prominent sx-38965f9b" >
22426 <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>
22427 <div class="code-sample sx-dc0bca3d" id="python-docstring-example" ></div>
22428 </div>
22429 </div>
22430 </div>
22431 <div class="subsection-bar">IEEE 1045-1992 counting</div>
22432 <div class="scan-rules-grid">
22433 <div class="preset-inline-row">
22434 <div class="toggle-card sx-38965f9b" >
22435 <div class="field-help-title">Continuation lines</div>
22436 <h4 class="sx-0ace2b19" >Continuation-line policy</h4>
22437 <select name="continuation_line_policy" id="continuation_line_policy">
22438 <option value="each_physical_line" selected>Each physical line (default)</option>
22439 <option value="collapse_to_logical">Collapse to logical line</option>
22440 </select>
22441 </div>
22442 <div class="explainer-card prominent sx-38965f9b" >
22443 <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>
22444 <div class="code-sample sx-161ac0cc" >#define MAX(a, b) \
22445 ((a) > (b) ? (a) : (b))
22446# each_physical_line → 2 SLOC
22447# collapse_to_logical → 1 SLOC</div>
22448 </div>
22449 </div>
22450 <div class="preset-inline-row">
22451 <div class="toggle-card sx-38965f9b" >
22452 <div class="field-help-title">Block-comment blanks</div>
22453 <h4 class="sx-0ace2b19" >Blank lines in block comments</h4>
22454 <select name="blank_in_block_comment_policy" id="blank_in_block_comment_policy">
22455 <option value="count_as_comment" selected>Count as comment (default)</option>
22456 <option value="count_as_blank">Count as blank</option>
22457 </select>
22458 </div>
22459 <div class="explainer-card prominent sx-38965f9b" >
22460 <div class="advanced-rule-description"><strong>Purpose:</strong> Decides how blank lines that fall inside a <code class="sx-c42f23b8" >/* … */</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>
22461 <div class="code-sample sx-161ac0cc" >/*
22462 * Summary line
22463 * ← blank inside block comment
22464 * Detail line
22465 */
22466# count_as_comment → blank counts toward comments
22467# count_as_blank → blank counts toward blanks</div>
22468 </div>
22469 </div>
22470 <div class="preset-inline-row">
22471 <div class="toggle-card sx-38965f9b" >
22472 <div class="field-help-title">Compiler directives</div>
22473 <h4 class="sx-0ace2b19" >Count compiler directives</h4>
22474 <select name="count_compiler_directives" id="count_compiler_directives">
22475 <option value="enabled" selected>Include in code SLOC (default)</option>
22476 <option value="disabled">Exclude from code SLOC</option>
22477 </select>
22478 </div>
22479 <div class="explainer-card prominent sx-38965f9b" >
22480 <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 class="sx-c42f23b8" >#include</code> / <code class="sx-c42f23b8" >#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>
22481 <div class="code-sample sx-161ac0cc" >#include <stdio.h> ← compiler directive
22482#define BUF 256 ← compiler directive
22483int main() { … } ← code
22484# enabled → 3 code SLOC
22485# disabled → 1 code SLOC + 2 directive lines</div>
22486 </div>
22487 </div>
22488 </div>
22489
22490 <div class="subsection-bar">Code Style Analysis</div>
22491 <div class="scan-rules-grid">
22492 <div class="preset-inline-row">
22493 <div class="toggle-card sx-38965f9b" >
22494 <div class="field-help-title">Style analysis</div>
22495 <h4 class="sx-0ace2b19" >Enable style analysis</h4>
22496 <select name="style_analysis_enabled" id="style_analysis_enabled">
22497 <option value="enabled" selected>Enabled (default)</option>
22498 <option value="disabled">Disabled — skip style scoring</option>
22499 </select>
22500 </div>
22501 <div class="explainer-card prominent sx-38965f9b" >
22502 <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>
22503 <div class="code-sample sx-161ac0cc" ># style_analysis_enabled = true (default)
22504# style_analysis_enabled = false (skip, faster scan)
22505# Disabling removes the Code Style section from the report.</div>
22506 </div>
22507 </div>
22508 <div class="preset-inline-row">
22509 <div class="toggle-card sx-38965f9b" >
22510 <div class="field-help-title">Column-width threshold</div>
22511 <h4 class="sx-0ace2b19" >Line-length compliance column</h4>
22512 <select name="style_col_threshold" id="style_col_threshold">
22513 <option value="80" selected>80 columns (PEP 8, Google, gofmt)</option>
22514 <option value="100">100 columns (Uber Go, Google Java)</option>
22515 <option value="120">120 columns (Uber Go max, Kotlin)</option>
22516 </select>
22517 </div>
22518 <div class="explainer-card prominent sx-38965f9b" >
22519 <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>
22520 <div class="code-sample sx-161ac0cc" ># style_col_threshold = 80 (PEP 8, Google, gofmt)
22521# style_col_threshold = 100 (Uber Go, Google Java)
22522# style_col_threshold = 120 (Uber Go max, Kotlin)
22523# Files where <= 5% of lines exceed the limit
22524# are counted as "N-col compliant" in the report.</div>
22525 </div>
22526 </div>
22527 <div class="preset-inline-row">
22528 <div class="toggle-card sx-38965f9b" >
22529 <div class="field-help-title">Score alert threshold</div>
22530 <h4 class="sx-0ace2b19" >Low-score file alert</h4>
22531 <select name="style_score_threshold" id="style_score_threshold">
22532 <option value="0" selected>Off — no threshold (default)</option>
22533 <option value="40">40% — flag poorly styled files</option>
22534 <option value="50">50% — flag below-average files</option>
22535 <option value="60">60% — flag below-good files</option>
22536 <option value="70">70% — flag below-strong files</option>
22537 </select>
22538 </div>
22539 <div class="explainer-card prominent sx-38965f9b" >
22540 <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>
22541 <div class="code-sample sx-161ac0cc" ># style_score_threshold = 0 (off, default)
22542# style_score_threshold = 50 (flag files < 50%)
22543# Low-scoring files get a red left-border in the
22544# per-file style breakdown table.</div>
22545 </div>
22546 </div>
22547 </div>
22548
22549 <div class="always-tracked-tip">
22550 <div class="always-tracked-tip-icon">ℹ</div>
22551 <div class="always-tracked-tip-body">
22552 <div class="field-help-title">Always tracked — not configurable · What these settings change</div>
22553 <h4>Comment and blank-line basics & Lines on the boundary</h4>
22554 <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 class="sx-c42f23b8" >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>
22555 </div>
22556 </div>
22557
22558 <div class="subsection-bar">Advanced Metrics</div>
22559 <div class="scan-rules-grid">
22560 <div class="preset-inline-row">
22561 <div class="toggle-card sx-38965f9b" >
22562 <div class="field-help-title">COCOMO mode</div>
22563 <h4 class="sx-0ace2b19" >Cost estimation model</h4>
22564 <select name="cocomo_mode" id="cocomo_mode">
22565 <option value="organic" selected>Organic — small team, familiar domain (default)</option>
22566 <option value="semi_detached">Semi-detached — mixed constraints</option>
22567 <option value="embedded">Embedded — tight hardware/OS constraints</option>
22568 </select>
22569 </div>
22570 <div class="explainer-card prominent sx-38965f9b" >
22571 <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>
22572 <div class="code-sample sx-161ac0cc" ># Organic: Effort = 2.4 × KSLOC^1.05
22573# Semi-detached: Effort = 3.0 × KSLOC^1.12
22574# Embedded: Effort = 3.6 × KSLOC^1.20
22575# All modes: Schedule = 2.5 × Effort^d</div>
22576 </div>
22577 </div>
22578 <div class="preset-inline-row">
22579 <div class="toggle-card sx-38965f9b" >
22580 <div class="field-help-title">Complexity alert</div>
22581 <h4 class="sx-0ace2b19" >Complexity score alert threshold</h4>
22582 <input class="sx-31b6fe48" type="number" name="complexity_alert" id="complexity_alert" min="0" max="9999" placeholder="e.g. 100 — leave blank for no alert" />
22583 </div>
22584 <div class="explainer-card prominent sx-38965f9b" >
22585 <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>
22586 <div class="code-sample sx-161ac0cc" ># 0 or blank = no alert (default)
22587# 50 = flag any file with > 50 branch points
22588# 100 = flag any file with > 100 branch points
22589# Files above the threshold are highlighted
22590# in the result page metric strip.</div>
22591 </div>
22592 </div>
22593 <div class="preset-inline-row">
22594 <div class="toggle-card sx-38965f9b" >
22595 <div class="field-help-title">Git hotspots</div>
22596 <h4 class="sx-0ace2b19" >Activity window (days)</h4>
22597 <input class="sx-31b6fe48" type="number" name="activity_window" id="activity_window" min="0" max="3650" value="90" placeholder="e.g. 90 — set 0 to disable" />
22598 </div>
22599 <div class="explainer-card prominent sx-38965f9b" >
22600 <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>
22601 <div class="code-sample sx-161ac0cc" ># 90 = last quarter (default)
22602# 30 = last month of activity
22603# 365 = last year
22604# 0 = disable the hotspots table
22605# Adds Commits + Last-changed columns to CSV.</div>
22606 </div>
22607 </div>
22608 <div class="preset-inline-row">
22609 <div class="toggle-card sx-38965f9b" >
22610 <div class="field-help-title">Code ownership</div>
22611 <h4 class="sx-0ace2b19" >Per-author attribution (git blame)</h4>
22612 <select name="attribution" id="attribution">
22613 <option value="enabled" selected>On — attribute lines per author (default)</option>
22614 <option value="disabled">Off — skip the blame pass</option>
22615 </select>
22616 <div class="attrib-estimate hidden" id="attrib-estimate" role="status"></div>
22617 </div>
22618 <div class="explainer-card prominent sx-38965f9b" >
22619 <div class="advanced-rule-description"><strong>Purpose:</strong> When on, oxide-sloc runs <code>git blame</code> on every analyzed file and attributes each physical line to the author who last touched it, split into <strong>code / comment / blank</strong> per contributor. Results appear on the Code Ownership page and in the HTML/PDF/CSV reports.<br /><strong>Requires</strong> the scanned path to be a git repository. <strong>On by default.</strong><br /><strong>Speed:</strong> the blame pass runs after file counting and is the slowest stage on big trees — it does one <code>git blame</code> per file, so cost scales with file count (submodules included). It is parallelized across CPU cores and the scan screen shows a live "Blamed N / M" counter, but on a repo with tens of thousands of files it can still take a few minutes. Turn it off for the fastest possible scan when you don't need ownership data. Same-email identities are merged automatically and the repo <code>.mailmap</code> is honoured.</div>
22620 <div class="code-sample sx-161ac0cc" ># On = per-author code/comment/blank ownership (default)
22621# Off = skip blame, no ownership data
22622# CLI equivalent: analyze --no-attribution to disable
22623# View results at /code-ownership</div>
22624 </div>
22625 </div>
22626 <div class="preset-inline-row">
22627 <div class="toggle-card sx-38965f9b" >
22628 <div class="field-help-title">Duplicate handling</div>
22629 <h4 class="sx-0ace2b19" >Duplicate file detection</h4>
22630 <select name="exclude_duplicates" id="exclude_duplicates">
22631 <option value="disabled" selected>Detect and report only (default)</option>
22632 <option value="enabled">Detect and exclude from SLOC totals</option>
22633 </select>
22634 </div>
22635 <div class="explainer-card prominent sx-38965f9b" >
22636 <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>
22637 <div class="code-sample sx-161ac0cc" ># A repo with 3 identical config files:
22638# detect only → all 3 counted in SLOC
22639# exclude dupes → 1 counted, 2 excluded
22640# Duplicate groups chip always shows the count.</div>
22641 </div>
22642 </div>
22643 <div class="always-tracked-tip sx-a9d31d9e" >
22644 <div class="always-tracked-tip-icon">ℹ</div>
22645 <div class="always-tracked-tip-body">
22646 <div class="field-help-title">Always computed — every scan produces these automatically</div>
22647 <div class="always-tracked-metrics-row">
22648 <div><strong>Cyclomatic complexity</strong>Counts branch keywords per file.</div>
22649 <div><strong>Logical SLOC</strong>Executable statements — C-family, Python, Ruby, Shell & more.</div>
22650 <div><strong>ULOC & DRYness</strong>De-duplicates lines project-wide; DRYness % = ULOC ÷ Code Lines.</div>
22651 <div><strong>COCOMO I</strong>Converts total SLOC into effort, schedule & team-size estimates.</div>
22652 </div>
22653 <div class="hint sx-a33b8fc2" >All four appear in the results page. The settings above only affect how they are displayed or whether edge cases are excluded.</div>
22654 </div>
22655 </div>
22656 </div>
22657
22658 <div class="wizard-actions">
22659 <div class="left">
22660 <button type="button" class="secondary prev-step" data-prev="1">Back</button>
22661 </div>
22662 <div class="right">
22663 <button type="button" class="secondary next-step" data-next="3">Next: Outputs and reports</button>
22664 </div>
22665 </div>
22666 </div>
22667
22668 <div class="wizard-step" data-step="3">
22669 <div class="section">
22670 <div class="section-kicker">Step 3</div>
22671 <h2>Output and report identity</h2>
22672 <p class="card-subtitle step3-subtitle sx-32fb29ef" >Choose where generated files should be saved, what the exported report title should be, and which artifact bundle fits your workflow.</p>
22673 <div class="preset-kv-row">
22674 <div class="toggle-card sx-38965f9b" >
22675 <div class="field-help-title sx-fffdd52c" >Scan configuration</div>
22676 <h4 class="sx-58629c9c" >Scan preset</h4>
22677 <select id="scan_preset">
22678 <option value="balanced">Balanced local scan</option>
22679 <option value="code_focused">Code focused</option>
22680 <option value="comment_audit">Comment audit</option>
22681 <option value="deep_review">Deep review</option>
22682 </select>
22683 <div class="hint">A scan preset applies recommended defaults for the kind of review you want to do.</div>
22684 </div>
22685 <div class="explainer-card">
22686 <div class="field-help-title">Selected scan preset</div>
22687 <div class="explainer-body" id="scan-preset-description"></div>
22688 <div class="preset-summary-row" id="scan-preset-summary"></div>
22689 <div class="code-sample" id="scan-preset-example"></div>
22690 <div class="preset-note" id="scan-preset-note"></div>
22691 </div>
22692 </div>
22693 <hr class="step3-separator" />
22694 <div class="preset-kv-row">
22695 <div class="toggle-card sx-38965f9b" >
22696 <div class="field-help-title sx-fffdd52c" >Output configuration</div>
22697 <h4 class="sx-58629c9c" >Artifact preset</h4>
22698 <select id="artifact_preset">
22699 <option value="review">Review bundle</option>
22700 <option value="full">Full bundle</option>
22701 <option value="html_only">HTML only</option>
22702 <option value="machine">Machine bundle</option>
22703 </select>
22704 <div class="hint">An artifact preset toggles the outputs below for browser review, handoff, or automation.</div>
22705 </div>
22706 <div class="explainer-card">
22707 <div class="field-help-title">Selected artifact preset</div>
22708 <div class="explainer-body" id="artifact-preset-description"></div>
22709 <div class="preset-summary-row" id="artifact-preset-summary"></div>
22710 <div class="code-sample" id="artifact-preset-example"></div>
22711 </div>
22712 </div>
22713 </div>
22714
22715 <div class="section section-spacer-top">
22716 <div class="output-field-row">
22717 <div class="field">
22718 <label for="output_dir">Output directory</label>
22719 {% if server_mode %}
22720 <div class="input-group compact">
22721 <input class="sx-338e056b" id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" readonly />
22722 </div>
22723 <div class="hint">Output path is managed by the server — each run stores artifacts in a unique timestamped subfolder automatically.</div>
22724 {% else %}
22725 <div class="input-group compact">
22726 <input id="output_dir" name="output_dir" type="text" value="" placeholder="auto: project/sloc" />
22727 <button type="button" class="mini-button oxide" id="browse-output-dir">Browse</button>
22728 <button type="button" class="mini-button" id="use-default-output">Use default</button>
22729 </div>
22730 <div class="hint">A unique timestamped subfolder is created automatically for each run — your existing files are never overwritten.</div>
22731 {% endif %}
22732 </div>
22733 <div class="output-field-aside">
22734 <strong>Where reports land</strong>
22735 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.
22736 </div>
22737 </div>
22738 </div>
22739
22740 <div class="section section-spacer-top">
22741 <div class="output-field-row">
22742 <div class="field">
22743 <label for="report_title">Report title</label>
22744 <input id="report_title" name="report_title" type="text" value="" placeholder="Project report title" />
22745 <div class="hint">Appears in HTML and PDF output headers.</div>
22746 </div>
22747 <div class="output-field-aside">
22748 <strong>Shown in exported artifacts</strong>
22749 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.
22750 </div>
22751 </div>
22752 </div>
22753
22754 <div class="section section-spacer-top">
22755 <div class="output-field-row">
22756 <div class="field">
22757 <label for="report_header_footer">Report header / footer</label>
22758 <input id="report_header_footer" name="report_header_footer" type="text" value="" placeholder="e.g. Acme Corp — Confidential · Project Athena" />
22759 <div class="hint sx-72d22148" >Printed on every HTML/PDF page — company name, project ID, or scanner tag.</div>
22760 </div>
22761 <div class="output-field-aside">
22762 <strong>Page-level identification</strong>
22763 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.
22764 </div>
22765 </div>
22766 </div>
22767
22768 <div class="wizard-actions">
22769 <div class="left">
22770 <button type="button" class="secondary prev-step" data-prev="2">Back</button>
22771 </div>
22772 <div class="right">
22773 <button type="button" class="secondary next-step" data-next="4">Next: Review and run</button>
22774 </div>
22775 </div>
22776 </div>
22777
22778 <div class="wizard-step" data-step="4">
22779 <div class="section">
22780 <div class="section-kicker">Step 4</div>
22781 <h2>Review selections and run</h2>
22782 <p class="card-subtitle">Check the selected path, counting policy, artifact bundle, output destination, and preview scope before launching the scan.</p>
22783 <div class="review-grid">
22784 <div class="review-card highlight">
22785 <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>
22786 <ul id="review-scan-summary"></ul>
22787 </div>
22788 <div class="review-card highlight">
22789 <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>
22790 <ul id="review-count-summary"></ul>
22791 </div>
22792 <div class="review-card">
22793 <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>
22794 <ul id="review-artifact-summary"></ul>
22795 <ul class="sx-44293b6e" id="review-output-summary" ></ul>
22796 </div>
22797 <div class="review-card">
22798 <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>
22799 <ul id="review-preview-summary"></ul>
22800 </div>
22801 </div>
22802 <div class="review-attrib-warn hidden" id="review-attrib-warn" role="alert"></div>
22803 </div>
22804
22805 <div class="wizard-actions">
22806 <div class="left">
22807 <button type="button" class="secondary prev-step" data-prev="3">Back</button>
22808 </div>
22809 <div class="right">
22810 <button type="submit" id="submit-button" class="primary">Run analysis</button>
22811 </div>
22812 </div>
22813 </div>
22814 {% if server_mode %}
22815 <input class="sx-6aa34d74" type="file" id="dir-upload-input" webkitdirectory multiple aria-hidden="true">
22816 <input class="sx-6aa34d74" type="file" id="cov-upload-input" accept=".info,.lcov,.xml,.json" aria-hidden="true">
22817 {% endif %}
22818 </form>
22819 </div>
22820 </section>
22821 </div>
22822 </div>
22823
22824 <script nonce="{{ csp_nonce }}">
22825 (function () {
22826 function startScanPhase() {
22827 var phaseEl = document.getElementById("scan-phase");
22828 if (!phaseEl) return;
22829 var phases = [
22830 "Discovering files...",
22831 "Decoding file encodings...",
22832 "Detecting languages...",
22833 "Analyzing source lines...",
22834 "Applying counting policies...",
22835 "Aggregating results...",
22836 "Rendering report..."
22837 ];
22838 var durations = [800, 600, 1200, 3000, 1000, 800, 600];
22839 var i = 0;
22840 function next() {
22841 phaseEl.style.opacity = "0";
22842 setTimeout(function () {
22843 phaseEl.textContent = phases[i];
22844 phaseEl.style.opacity = "0.85";
22845 var delay = durations[i] || 1800;
22846 i++;
22847 if (i < phases.length) { setTimeout(next, delay); }
22848 }, 200);
22849 }
22850 next();
22851 }
22852
22853 var form = document.getElementById("analyze-form");
22854 var loading = document.getElementById("loading");
22855 var submitButton = document.getElementById("submit-button");
22856 var pathInput = document.getElementById("path");
22857 var GIT_MODE = !!(pathInput && pathInput.readOnly);
22858 var GIT_LABEL = GIT_MODE ? {{ git_label_json|safe }} : "";
22859 var GIT_OUTPUT_DIR = GIT_MODE ? {{ git_output_dir_json|safe }} : "";
22860 var outputDirInput = document.getElementById("output_dir");
22861 var reportTitleInput = document.getElementById("report_title");
22862 var previewPanel = document.getElementById("preview-panel");
22863 var refreshButton = document.getElementById("refresh-preview");
22864 var refreshPreviewInline = document.getElementById("refresh-preview-inline");
22865 var useSamplePath = document.getElementById("use-sample-path");
22866 var useDefaultOutput = document.getElementById("use-default-output");
22867 var browsePath = document.getElementById("browse-path");
22868 var browseOutputDir = document.getElementById("browse-output-dir");
22869 var browseCoverage = document.getElementById("browse-coverage");
22870 var coverageInput = document.getElementById("coverage_file");
22871 var covScanStatus = document.getElementById("cov-scan-status");
22872 var coverageSuggestTimer = null;
22873 var covAutoFilled = false;
22874 var SERVER_MODE = {% if server_mode %}true{% else %}false{% endif %};
22875
22876 // Scroll long path inputs to end on blur (replaces inline onblur="..." removed for CSP).
22877 (function() {
22878 var ids = ["path", "output_dir"];
22879 ids.forEach(function(id) {
22880 var el = document.getElementById(id);
22881 if (el) el.addEventListener("blur", function() { this.scrollLeft = this.scrollWidth; });
22882 });
22883 }());
22884 function fmtBytes(b) {
22885 b = Number(b) || 0;
22886 if (b >= 1073741824) return (b / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GB';
22887 if (b >= 1048576) return (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
22888 if (b >= 1024) return Math.round(b / 1024) + ' KB';
22889 return b + ' B';
22890 }
22891 var themeToggle = document.getElementById("theme-toggle");
22892
22893 function showBannerToast(msg, isError, opts) {
22894 opts = opts || {};
22895 var t = document.createElement('div');
22896 t.className = isError ? 'toast-error' : 'toast-success';
22897 var topPos = opts.top ? '80px' : null;
22898 t.style.cssText = 'position:fixed;' + (topPos ? 'top:' + topPos + ';' : 'bottom:24px;') +
22899 'left:50%;transform:translateX(-50%);z-index:9999;min-width:320px;max-width:560px;' +
22900 'box-shadow:0 8px 32px rgba(0,0,0,0.22);padding:14px 20px;border-radius:12px;' +
22901 'font-size:13px;font-weight:600;line-height:1.5;text-align:center;';
22902 if (opts.icon) {
22903 var inner = document.createElement('span');
22904 inner.innerHTML = opts.icon + ' ';
22905 t.appendChild(inner);
22906 }
22907 t.appendChild(document.createTextNode(msg));
22908 document.body.appendChild(t);
22909 setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 5500);
22910 }
22911 var mixedLinePolicy = document.getElementById("mixed_line_policy");
22912 var pythonDocstrings = document.getElementById("python_docstrings_as_comments");
22913 var pythonWraps = document.querySelectorAll(".python-docstring-wrap");
22914 var scanPreset = document.getElementById("scan_preset");
22915 var artifactPreset = document.getElementById("artifact_preset");
22916 var includeGlobsInput = document.getElementById("include_globs");
22917 var excludeGlobsInput = document.getElementById("exclude_globs");
22918
22919 // Include globs scope badge — updates reactively as the user types.
22920 (function() {
22921 var badge = document.getElementById("include-scope-badge");
22922 if (!badge || !includeGlobsInput) return;
22923 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> ';
22924 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> ';
22925 function update() {
22926 var val = includeGlobsInput.value.trim();
22927 if (!val) {
22928 badge.className = "include-scope-badge scope-all";
22929 badge.innerHTML = iconCheck + "All files eligible \u2014 no include filter active";
22930 } else {
22931 var count = val.split(/[\n,]+/).filter(function(s) { return s.trim(); }).length;
22932 badge.className = "include-scope-badge scope-narrow";
22933 badge.innerHTML = iconFilter + "Scoped to " + count + " pattern" + (count === 1 ? "" : "s") + " \u2014 only matching files will be included";
22934 }
22935 }
22936 includeGlobsInput.addEventListener("input", update);
22937 update();
22938 }());
22939
22940 // Quick-exclude chips — append pattern to exclude_globs textarea.
22941 document.querySelectorAll(".quick-excl-chip").forEach(function(chip) {
22942 chip.addEventListener("click", function() {
22943 var pattern = chip.getAttribute("data-pattern") || "";
22944 if (!pattern || !excludeGlobsInput) return;
22945 var current = excludeGlobsInput.value.trim();
22946 // For the "skip all" chip, replace any existing dep patterns cleanly.
22947 var patterns = pattern.split("\n");
22948 var lines = current ? current.split("\n").map(function(l) { return l.trim(); }).filter(Boolean) : [];
22949 var added = false;
22950 patterns.forEach(function(p) {
22951 p = p.trim();
22952 if (p && lines.indexOf(p) === -1) { lines.push(p); added = true; }
22953 });
22954 if (added) {
22955 excludeGlobsInput.value = lines.join("\n");
22956 excludeGlobsInput.dispatchEvent(new Event("input"));
22957 }
22958 chip.classList.add("active");
22959 });
22960 });
22961
22962 var liveReportTitle = document.getElementById("live-report-title");
22963 var navProjectPill = document.getElementById("nav-project-pill");
22964 var navProjectTitle = document.getElementById("nav-project-title");
22965 var reportTitlePreview = null;
22966 var wizardProgressFill = document.getElementById("wizard-progress-fill");
22967 var wizardProgressValue = document.getElementById("wizard-progress-value");
22968 var stepButtons = Array.prototype.slice.call(document.querySelectorAll(".step-button"));
22969 var stepPanels = Array.prototype.slice.call(document.querySelectorAll(".wizard-step"));
22970 var reportTitleTouched = false;
22971 var currentStep = 1;
22972 var previewTimer = null;
22973 var _previewGen = 0;
22974 // True while the scope preview (local) / project upload (server mode) is in
22975 // flight. The step 1 -> 2 "Next" button is blocked until it settles so the
22976 // user can't advance past a project whose scope/upload isn't ready yet.
22977 var previewLoading = false;
22978 // Set when the current preview reports multiple independent git repos under
22979 // the selected root. Advancing past step 1 is blocked until the user ticks
22980 // the acknowledgement checkbox (or re-selects a single repository).
22981 var multiRepoBlocked = false;
22982 function step1ForwardBlocked() {
22983 return previewLoading || multiRepoBlocked;
22984 }
22985 function refreshStep1Gate() {
22986 var nextBtn = document.getElementById("step1-next");
22987 if (nextBtn) {
22988 var blocked = step1ForwardBlocked();
22989 nextBtn.classList.toggle("is-blocked", blocked);
22990 nextBtn.setAttribute("aria-disabled", blocked ? "true" : "false");
22991 }
22992 }
22993 function setPreviewLoading(loading) {
22994 previewLoading = !!loading;
22995 var gate = document.getElementById("preview-gate-status");
22996 refreshStep1Gate();
22997 if (gate) {
22998 var txt = gate.querySelector(".preview-gate-text");
22999 if (txt) txt.textContent = SERVER_MODE
23000 ? "Uploading & scanning project…"
23001 : "Scanning project scope…";
23002 gate.style.display = previewLoading ? "flex" : "none";
23003 }
23004 }
23005 // Info button on the gate: scroll up to the live scope preview so the user
23006 // can see exactly what is being scanned (elapsed time + rotating status).
23007 var previewGateInfo = document.getElementById("preview-gate-info");
23008 if (previewGateInfo) {
23009 previewGateInfo.addEventListener("click", function () {
23010 var target = document.getElementById("preview-panel");
23011 if (!target) return;
23012 target.scrollIntoView({ behavior: "smooth", block: "center" });
23013 target.classList.add("preview-panel-flash");
23014 setTimeout(function () { target.classList.remove("preview-panel-flash"); }, 1400);
23015 });
23016 }
23017 var quickScanBtn = document.getElementById("quick-scan-btn");
23018
23019 function dismissAnalysisModal() {
23020 if (loading) loading.classList.remove("active");
23021 document.body.classList.remove("modal-open");
23022 ["lc-err","lc-warn","lc-attrib-note","lc-actions","lc-cancelled"].forEach(function(id) {
23023 var el = document.getElementById(id);
23024 if (el) el.classList.add("hidden");
23025 });
23026 var cancelBtn = document.getElementById("lc-cancel-btn");
23027 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; cancelBtn.textContent = "\u2715 Cancel scan"; }
23028 var el = document.getElementById("lc-elapsed"); if (el) el.textContent = "0s";
23029 var ph = document.getElementById("lc-phase"); if (ph) ph.textContent = "Starting";
23030 var sd = document.getElementById("lc-stage-desc"); if (sd) sd.textContent = "Initializing language analyzers and loading configuration\u2026";
23031 for (var ri=1;ri<=5;ri++){var rs=document.getElementById("lc-step-"+ri);if(!rs)continue;rs.classList.remove("active","done");if(ri===1)rs.classList.add("active");}
23032 var rof=document.getElementById("lc-overall-fill");if(rof)rof.style.width="0%";
23033 var rop=document.getElementById("lc-overall-pct");if(rop)rop.textContent="0%";
23034 var rsc=document.getElementById("lc-speed-card");if(rsc)rsc.classList.add("hidden");
23035 var rfc=document.getElementById("lc-files-card");if(rfc){rfc.classList.add("hidden");var rfl=rfc.querySelector(".lc-metric-label");if(rfl)rfl.textContent="Files";}
23036 var rslbl=rsc?rsc.querySelector(".lc-metric-label"):null;if(rslbl)rslbl.textContent="Files/sec";
23037 var rcard = document.getElementById("loading-card"); if (rcard) rcard.classList.add("lc-pulsing");
23038 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
23039 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
23040 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
23041 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
23042 }
23043
23044 var lcDismissBtn = document.getElementById("lc-dismiss");
23045 if (lcDismissBtn) lcDismissBtn.addEventListener("click", dismissAnalysisModal);
23046
23047 // When the browser restores this page from bfcache (Back button after navigating to results),
23048 // the loading overlay would still be showing its active state. Dismiss it immediately.
23049 window.addEventListener("pageshow", function(e) {
23050 if (e.persisted) { dismissAnalysisModal(); }
23051 });
23052
23053 function startAsyncAnalysis(formData) {
23054 var gitRepo = (formData.get("git_repo") || "").toString();
23055 var gitRef = (formData.get("git_ref") || "").toString();
23056 var pathVal = (gitRepo || (formData.get("path") || "")).toString();
23057 var displayPath = (gitRepo && gitRef) ? pathVal + " @ " + gitRef : pathVal;
23058
23059 var pathEl = document.getElementById("lc-path-text");
23060 if (pathEl) pathEl.textContent = displayPath;
23061
23062 ["lc-err","lc-warn","lc-attrib-note","lc-actions","lc-cancelled"].forEach(function(id) {
23063 var el = document.getElementById(id);
23064 if (el) el.classList.add("hidden");
23065 });
23066 var cancelBtn = document.getElementById("lc-cancel-btn");
23067 if (cancelBtn) { cancelBtn.style.display = ""; cancelBtn.disabled = false; }
23068 var startCard = document.getElementById("loading-card"); if (startCard) startCard.classList.add("lc-pulsing");
23069 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "";
23070 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "";
23071 var elapsed0 = document.getElementById("lc-elapsed"); if (elapsed0) elapsed0.textContent = "0s";
23072 var phase0 = document.getElementById("lc-phase"); if (phase0) phase0.textContent = "Starting";
23073 var sd0 = document.getElementById("lc-stage-desc"); if (sd0) sd0.textContent = "Initializing language analyzers and loading configuration\u2026";
23074 for (var si=1;si<=5;si++){var ss=document.getElementById("lc-step-"+si);if(!ss)continue;ss.classList.remove("active","done");if(si===1)ss.classList.add("active");}
23075 var sc0=document.getElementById("lc-speed-card");if(sc0)sc0.classList.add("hidden");
23076 var of0=document.getElementById("lc-overall-fill");if(of0)of0.style.width="0%";
23077 var op0=document.getElementById("lc-overall-pct");if(op0)op0.textContent="0%";
23078
23079 if (loading) loading.classList.add("active");
23080 document.body.classList.add("modal-open");
23081
23082 var startTime = Date.now();
23083 var elapsedTimer = setInterval(function() {
23084 var s = Math.floor((Date.now() - startTime) / 1000);
23085 var el = document.getElementById("lc-elapsed");
23086 if (el) el.textContent = s < 60 ? s + "s" : Math.floor(s/60) + "m " + (s%60) + "s";
23087 }, 1000);
23088
23089 var warnShown = false, pollRetries = 0, activeWaitId = null, lastFd = 0, lastFdTime = Date.now(), lastWasAttrib = false;
23090
23091 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();}
23092
23093 var PHASE_DESC = {
23094 'Starting': 'Initializing language analyzers and loading configuration\u2026',
23095 'Scanning files': 'Walking the directory tree, applying scope filters, and reading file bytes\u2026',
23096 'Running': 'Running the lexical state machine across all discovered source files\u2026',
23097 'Attributing authorship': 'Running git blame on every source file (including submodules) to attribute each line to its author \u2014 the slowest stage on large repositories\u2026',
23098 'Summarizing submodules': 'Detecting git submodules and rolling up per-submodule line totals\u2026',
23099 'Computing metrics': 'Computing ULOC, duplicate groups, complexity and COCOMO estimates\u2026',
23100 'Reading git history': 'Reading recent git history to rank change hotspots\u2026',
23101 'Writing reports': 'Rendering the HTML report and saving JSON artifacts to disk\u2026',
23102 'Done': 'Analysis complete \u2014 loading your results\u2026',
23103 'Failed': 'Analysis encountered an error. Check the path and permissions, then try again.'
23104 };
23105 // 5 stages: 1 Discover (walk+count), 2 Analyze (metrics/submodules/git history),
23106 // 3 Attribute (git blame), 4 Report (write artifacts), 5 Done.
23107 var PHASE_STEP = {'Starting':1,'Scanning files':1,'Running':1,'Summarizing submodules':2,'Computing metrics':2,'Reading git history':2,'Attributing authorship':3,'Writing reports':4,'Done':5};
23108 // Overall-progress bands [start,end] per phase. The counter-driven phases (Discover via
23109 // files, Attribute via blame) scale within their band; brief phases jump to their band end.
23110 // Bands are weighted by real time cost: on a large repo attribution is ~99% of the wall time,
23111 // so it owns the widest band and the bar spends most of its life there instead of at "done".
23112 var PHASE_BAND = {
23113 'Starting':[0,2],'Scanning files':[2,15],'Running':[2,15],
23114 'Summarizing submodules':[15,18],'Computing metrics':[18,20],'Reading git history':[20,22],
23115 'Attributing authorship':[22,97],'Writing reports':[97,99],'Done':[100,100],'Failed':[100,100]
23116 };
23117 var lastPct = 0;
23118 function overallPct(data) {
23119 var phase = (data && data.phase) || 'Starting';
23120 var band = PHASE_BAND[phase] || [lastPct, lastPct];
23121 var frac = 1;
23122 if (phase === 'Scanning files' || phase === 'Running') {
23123 frac = (data.files_total > 0) ? (data.files_done / data.files_total) : 0;
23124 } else if (phase === 'Attributing authorship') {
23125 frac = (data.attrib_total > 0) ? (data.attrib_done / data.attrib_total) : 0;
23126 }
23127 var pct = band[0] + (band[1] - band[0]) * Math.max(0, Math.min(1, frac));
23128 lastPct = Math.max(lastPct, pct); // never let the bar go backwards
23129 return lastPct;
23130 }
23131 function setOverall(pct) {
23132 var fill = document.getElementById("lc-overall-fill");
23133 var lbl = document.getElementById("lc-overall-pct");
23134 var p = Math.max(0, Math.min(100, Math.round(pct)));
23135 if (fill) fill.style.width = p + "%";
23136 if (lbl) lbl.textContent = p + "%";
23137 }
23138 function lcSetPhase(txt) {
23139 var el = document.getElementById("lc-phase"); if (el) el.textContent = txt;
23140 var desc = document.getElementById("lc-stage-desc");
23141 if (desc) desc.textContent = PHASE_DESC[txt] || (txt + '\u2026');
23142 var step = PHASE_STEP[txt] || 1;
23143 for (var i=1;i<=5;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");}
23144 }
23145
23146 function lcShowCancelled() {
23147 clearInterval(elapsedTimer);
23148 var ccard = document.getElementById("loading-card"); if (ccard) ccard.classList.remove("lc-pulsing");
23149 var metrics = document.getElementById("lc-metrics"); if (metrics) metrics.style.display = "none";
23150 var pb = document.getElementById("lc-progress-bar"); if (pb) pb.style.display = "none";
23151 var warnEl = document.getElementById("lc-warn"); if (warnEl) warnEl.classList.add("hidden");
23152 var cancelledEl = document.getElementById("lc-cancelled"); if (cancelledEl) cancelledEl.classList.remove("hidden");
23153 var actEl = document.getElementById("lc-actions"); if (actEl) actEl.classList.remove("hidden");
23154 var cancelBtn = document.getElementById("lc-cancel-btn"); if (cancelBtn) cancelBtn.style.display = "none";
23155 var titleEl = document.getElementById("lc-title"); if (titleEl) titleEl.textContent = "Scan cancelled";
23156 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
23157 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
23158 }
23159
23160 var lcCancelBtn = document.getElementById("lc-cancel-btn");
23161 if (lcCancelBtn) {
23162 lcCancelBtn.onclick = function() {
23163 if (!activeWaitId) { dismissAnalysisModal(); return; }
23164 lcCancelBtn.disabled = true;
23165 lcCancelBtn.textContent = "Cancelling\u2026";
23166 fetch("/api/runs/" + encodeURIComponent(activeWaitId) + "/cancel", { method: "POST" })
23167 .then(function() { lcShowCancelled(); })
23168 .catch(function() { lcShowCancelled(); });
23169 };
23170 }
23171
23172 function lcShowError(msg) {
23173 clearInterval(elapsedTimer);
23174 var ecard = document.getElementById("loading-card"); if (ecard) ecard.classList.remove("lc-pulsing");
23175 lcSetPhase("Failed");
23176 var msgEl = document.getElementById("lc-err-msg");
23177 if (msgEl) msgEl.textContent = msg || "Analysis failed.";
23178 var errEl = document.getElementById("lc-err");
23179 var actEl = document.getElementById("lc-actions");
23180 if (errEl) errEl.classList.remove("hidden");
23181 if (actEl) actEl.classList.remove("hidden");
23182 if (submitButton) { submitButton.disabled = false; submitButton.textContent = "Run analysis"; }
23183 if (quickScanBtn) { quickScanBtn.disabled = false; quickScanBtn.textContent = "Quick Scan"; }
23184 }
23185
23186 function lcPoll(waitId) {
23187 fetch("/api/runs/" + encodeURIComponent(waitId) + "/status")
23188 .then(function(r) {
23189 if (!r.ok) throw new Error("HTTP " + r.status);
23190 return r.json();
23191 })
23192 .then(function(data) {
23193 pollRetries = 0;
23194 if (data.state === "complete") {
23195 clearInterval(elapsedTimer);
23196 lcSetPhase("Done");
23197 setOverall(100);
23198 window.location.href = "/runs/result/" + encodeURIComponent(data.run_id);
23199 } else if (data.state === "failed") {
23200 lcShowError(data.message);
23201 } else if (data.state === "cancelled") {
23202 lcShowCancelled();
23203 } else {
23204 var s = Math.floor((Date.now() - startTime) / 1000);
23205 if (s > 90 && !warnShown) {
23206 warnShown = true;
23207 var w = document.getElementById("lc-warn");
23208 if (w) w.classList.remove("hidden");
23209 }
23210 lcSetPhase(data.phase || "Running");
23211 // During the git-blame attribution pass the file counter is already maxed out, so
23212 // switch the live metric over to blame progress — otherwise the modal looks frozen
23213 // at "files done / files total" for the slowest stage of the whole scan.
23214 var attribActive = (data.attrib_total || 0) > 0;
23215 var curDone = attribActive ? (data.attrib_done || 0) : (data.files_done || 0);
23216 var curTotal = attribActive ? (data.attrib_total || 0) : (data.files_total || 0);
23217 var attribNote = document.getElementById("lc-attrib-note");
23218 if (attribNote) attribNote.classList.toggle("hidden", !attribActive);
23219 if (curTotal > 0) {
23220 var card = document.getElementById("lc-files-card");
23221 if (card) card.classList.remove("hidden");
23222 var fLabel = card ? card.querySelector(".lc-metric-label") : null;
23223 if (fLabel) fLabel.textContent = attribActive ? "Blamed" : "Files";
23224 var el = document.getElementById("lc-files");
23225 if (el) el.textContent = fmt(curDone) + " / " + fmt(curTotal);
23226 var now = Date.now();
23227 // Reset the rate baseline when the counter source flips (files -> blamed) so the
23228 // speed reading doesn't show a bogus negative spike on the transition.
23229 if (attribActive !== lastWasAttrib) { lastFd = attribActive ? 0 : curDone; }
23230 var fdelta = curDone - lastFd, tdelta = (now - lastFdTime) / 1000;
23231 if (fdelta > 0 && tdelta > 0.4) {
23232 var fps = Math.round(fdelta / tdelta);
23233 var spEl = document.getElementById("lc-speed"); if (spEl) spEl.textContent = fmt(fps);
23234 var spCard = document.getElementById("lc-speed-card"); if (spCard) spCard.classList.remove("hidden");
23235 var spLabel = spCard ? spCard.querySelector(".lc-metric-label") : null;
23236 if (spLabel) spLabel.textContent = attribActive ? "Blamed/sec" : "Files/sec";
23237 }
23238 lastFd = curDone; lastFdTime = now; lastWasAttrib = attribActive;
23239 }
23240 setOverall(overallPct(data));
23241 setTimeout(function() { lcPoll(waitId); }, 1500);
23242 }
23243 })
23244 .catch(function() {
23245 pollRetries++;
23246 if (pollRetries >= 5) {
23247 lcShowError("Lost connection to server. Reload to check status.");
23248 } else {
23249 setTimeout(function() { lcPoll(waitId); }, Math.min(1500 * Math.pow(2, pollRetries), 8000));
23250 }
23251 });
23252 }
23253
23254 var params = new URLSearchParams(formData);
23255 fetch("/analyze", { method: "POST", body: params, headers: { "Content-Type": "application/x-www-form-urlencoded" } })
23256 .then(function(r) {
23257 var waitId = r.headers.get("x-wait-id");
23258 if (!waitId) { window.location.href = "/scan"; return; }
23259 activeWaitId = waitId;
23260 setTimeout(function() { lcPoll(waitId); }, 1500);
23261 })
23262 .catch(function(err) {
23263 lcShowError("Could not reach server: " + (err.message || err));
23264 });
23265 }
23266
23267 if (quickScanBtn) {
23268 quickScanBtn.addEventListener("click", function () {
23269 var pathVal = pathInput ? pathInput.value.trim() : "";
23270 if (!pathVal) {
23271 alert("Please enter or browse to a project path first.");
23272 return;
23273 }
23274 quickScanBtn.disabled = true;
23275 quickScanBtn.textContent = "Scanning...";
23276 if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Scanning..."; }
23277 startAsyncAnalysis(new FormData(form));
23278 });
23279 }
23280
23281 var mixedPolicyInfo = {
23282 code_only: {
23283 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.",
23284 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'
23285 },
23286 code_and_comment: {
23287 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.",
23288 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'
23289 },
23290 comment_only: {
23291 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.",
23292 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'
23293 },
23294 separate_mixed_category: {
23295 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.",
23296 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'
23297 }
23298 };
23299
23300 var scanPresetInfo = {
23301 balanced: {
23302 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.",
23303 chips: ["Mixed: code only", "Docstrings: on", "Lockfiles: off", "Binary: skip"],
23304 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\nbinary_file_behavior = "skip"',
23305 note: "Best when you want a stable local overview before making deeper adjustments.",
23306 apply: { mixed: "code_only", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
23307 },
23308 code_focused: {
23309 description: "Code focused trims commentary-oriented interpretation so executable implementation stays front and center in the totals.",
23310 chips: ["Mixed: code only", "Docstrings: off", "Vendor guard: on", "Lockfiles: off"],
23311 example: 'mixed_line_policy = "code_only"\npython_docstrings_as_comments = false\ninclude_lockfiles = false\nvendor_directory_detection = "enabled"',
23312 note: "Use this when you mainly care about implementation size and want cleaner code totals.",
23313 apply: { mixed: "code_only", docstrings: false, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
23314 },
23315 comment_audit: {
23316 description: "Comment audit makes inline explanation and documentation density easier to inspect without changing the overall project scope too aggressively.",
23317 chips: ["Mixed: code + comment", "Docstrings: on", "Generated guard: on", "Binary: skip"],
23318 example: 'mixed_line_policy = "code_and_comment"\npython_docstrings_as_comments = true\ninclude_lockfiles = false\ngenerated_file_detection = "enabled"',
23319 note: "Useful when readability, annotations, or documentation habits are part of the review goal.",
23320 apply: { mixed: "code_and_comment", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "disabled", binary: "skip" }
23321 },
23322 deep_review: {
23323 description: "Deep review surfaces more nuance in the counts by separating mixed lines and pulling in a bit more repository metadata.",
23324 chips: ["Mixed: separate bucket", "Docstrings: on", "Lockfiles: on", "Binary: skip"],
23325 example: 'mixed_line_policy = "separate_mixed_category"\npython_docstrings_as_comments = true\ninclude_lockfiles = true\nbinary_file_behavior = "skip"',
23326 note: "Choose this when you want a richer review snapshot before producing saved reports or comparing future runs.",
23327 apply: { mixed: "separate_mixed_category", docstrings: true, generated: "enabled", minified: "enabled", vendor: "enabled", lockfiles: "enabled", binary: "skip" }
23328 }
23329 };
23330
23331 var artifactPresetInfo = {
23332 review: {
23333 description: "HTML report for in-browser review. No PDF or data exports \u2014 fast and lightweight.",
23334 chips: ["HTML", "no PDF", "no JSON/CSV/XLSX"],
23335 example: "Ideal for a quick local review before sharing results."
23336 },
23337 full: {
23338 description: "All artifacts: HTML, PDF, JSON, CSV, and XLSX. Best for handoff packages or archiving.",
23339 chips: ["HTML", "PDF", "JSON", "CSV", "XLSX"],
23340 example: "Use when producing a deliverable or storing a snapshot for future comparison."
23341 },
23342 html_only: {
23343 description: "Standalone HTML report only. No PDF generation, no data files.",
23344 chips: ["HTML only"],
23345 example: "Fastest option when you only need to open the report in a browser."
23346 },
23347 machine: {
23348 description: "JSON and CSV data files only \u2014 no HTML or PDF. Designed for CI pipelines and automation.",
23349 chips: ["JSON", "CSV", "no HTML", "no PDF"],
23350 example: "Use in CI to capture metrics without generating visual reports."
23351 }
23352 };
23353
23354 function applyArtifactPreset() {
23355 var info = artifactPresetInfo[artifactPreset ? artifactPreset.value : "review"];
23356 if (!info) return;
23357 var descEl = document.getElementById("artifact-preset-description");
23358 var exampleEl = document.getElementById("artifact-preset-example");
23359 if (descEl) descEl.textContent = info.description;
23360 if (exampleEl) exampleEl.textContent = info.example;
23361 renderPresetChips("artifact-preset-summary", info.chips);
23362 }
23363
23364 function applyTheme(theme) {
23365 if (theme === "dark") document.body.classList.add("dark-theme");
23366 else document.body.classList.remove("dark-theme");
23367 }
23368
23369 function loadSavedTheme() {
23370 var saved = null;
23371 try { saved = localStorage.getItem("oxide-sloc-theme"); } catch (e) {}
23372 applyTheme(saved === "dark" ? "dark" : "light");
23373 }
23374
23375 function updateScrollProgress() {
23376 // Step 1 starts at 0%, step 2 at 25%, step 3 at 50%, step 4 at 75%.
23377 // Within each step, scroll position nudges the bar forward (max just below the next milestone).
23378 var stepBase = [0, 0, 25, 50, 75]; // base % for steps 1-4 (index = step number)
23379 var stepEnd = [0, 24, 49, 74, 100]; // max % before clicking Next (step 4 can reach 100)
23380 var step = Math.min(Math.max(currentStep, 1), 4);
23381 var base = stepBase[step];
23382 var end = stepEnd[step];
23383
23384 var scrollFrac = 0;
23385 var activePanel = document.querySelector(".wizard-step.active");
23386 if (activePanel) {
23387 var scrollTop = window.scrollY || window.pageYOffset || 0;
23388 var panelTop = activePanel.getBoundingClientRect().top + scrollTop;
23389 var panelH = activePanel.scrollHeight || activePanel.offsetHeight || 1;
23390 var viewH = window.innerHeight || document.documentElement.clientHeight || 800;
23391 var scrolled = scrollTop + viewH - panelTop;
23392 scrollFrac = Math.min(1, Math.max(0, scrolled / (panelH + viewH * 0.4)));
23393 }
23394
23395 var percent = Math.round(base + (end - base) * scrollFrac);
23396 percent = Math.min(end, Math.max(base, percent));
23397 if (wizardProgressFill) wizardProgressFill.style.width = percent + "%";
23398 if (wizardProgressValue) wizardProgressValue.textContent = percent + "%";
23399 }
23400
23401 function updateWizardProgress() {
23402 updateScrollProgress();
23403 }
23404
23405 var stepDescriptions = [
23406 "Choose a project folder, apply scope filters, and preview which files will be counted.",
23407 "Configure how mixed code-plus-comment lines and docstrings are classified.",
23408 "Pick your output formats, scan preset, and where reports are saved.",
23409 "Review all settings and launch the analysis."
23410 ];
23411
23412 function updateStepNav(step) {
23413 var infoLabel = document.getElementById("step-nav-info-label");
23414 var infoDesc = document.getElementById("step-nav-info-desc");
23415 if (infoLabel) infoLabel.textContent = "Step " + step + " of 4";
23416 if (infoDesc) infoDesc.textContent = stepDescriptions[step - 1] || "";
23417 }
23418
23419 function updateSidebarSummary() {
23420 var sumPath = document.getElementById("sum-path");
23421 var sumPreset = document.getElementById("sum-preset");
23422 var sumOutput = document.getElementById("sum-output");
23423 var sidebarSummary = document.getElementById("sidebar-summary");
23424 var pathVal = (pathInput && pathInput.value.trim()) ? inferTitleFromPath(pathInput.value) : "";
23425 var presetVal = (scanPreset && scanPreset.value) ? scanPreset.value.replace(/_/g, " ") : "";
23426 var outputVal = (artifactPreset && artifactPreset.value) ? artifactPreset.value.replace(/_/g, " ") : "";
23427 if (sumPath) sumPath.textContent = pathVal || "\u2014";
23428 if (sumPreset) sumPreset.textContent = presetVal || "\u2014";
23429 if (sumOutput) sumOutput.textContent = outputVal || "\u2014";
23430 if (sidebarSummary) sidebarSummary.style.display = (pathVal || presetVal || outputVal) ? "" : "none";
23431 }
23432
23433 function setStep(step, pushHistory) {
23434 currentStep = step;
23435 stepPanels.forEach(function (panel) {
23436 panel.classList.toggle("active", Number(panel.getAttribute("data-step")) === step);
23437 });
23438 stepButtons.forEach(function (button) {
23439 button.classList.toggle("active", Number(button.getAttribute("data-step-target")) === step);
23440 });
23441 var layoutEl = document.querySelector(".layout");
23442 if (layoutEl) layoutEl.setAttribute("data-active-step", step);
23443 updateWizardProgress();
23444 updateStepNav(step);
23445 stepButtons.forEach(function(btn) {
23446 var t = Number(btn.getAttribute("data-step-target"));
23447 btn.classList.toggle("done", t < step);
23448 });
23449 updateSidebarSummary();
23450
23451 if (pushHistory !== false) {
23452 try {
23453 history.pushState({ wizardStep: step }, "", "#step" + step);
23454 } catch (e) {}
23455 }
23456
23457 window.scrollTo({ top: 0, behavior: "instant" });
23458 }
23459
23460 window.addEventListener("popstate", function (e) {
23461 if (e.state && e.state.wizardStep) {
23462 setStep(e.state.wizardStep, false);
23463 } else {
23464 var hashMatch = location.hash.match(/^#step([1-4])$/);
23465 if (hashMatch) setStep(Number(hashMatch[1]), false);
23466 }
23467 });
23468
23469 function inferTitleFromPath(value) {
23470 if (!value) return "project";
23471 var cleaned = value.replace(/[\/\\]+$/, "");
23472 var parts = cleaned.split(/[\/\\]/).filter(Boolean);
23473 return parts.length ? parts[parts.length - 1] : value;
23474 }
23475
23476 function updateReportTitleFromPath() {
23477 var inferred = (GIT_MODE && GIT_LABEL) ? GIT_LABEL : inferTitleFromPath(pathInput.value || "");
23478 if (!reportTitleTouched) {
23479 reportTitleInput.value = inferred;
23480 }
23481 var title = reportTitleInput.value || inferred;
23482 if (liveReportTitle) liveReportTitle.textContent = title;
23483 if (reportTitlePreview) reportTitlePreview.textContent = title;
23484 document.title = "OxideSLOC | " + title;
23485
23486 var projectPath = (pathInput.value || "").trim();
23487 if (navProjectPill && navProjectTitle) {
23488 if (projectPath.length > 0) {
23489 navProjectTitle.textContent = inferred;
23490 navProjectPill.classList.add("visible");
23491 } else {
23492 navProjectTitle.textContent = "";
23493 navProjectPill.classList.remove("visible");
23494 }
23495 }
23496 }
23497
23498 function updateMixedPolicyUI() {
23499 var key = mixedLinePolicy.value || "code_only";
23500 var info = mixedPolicyInfo[key];
23501 document.getElementById("mixed-policy-description").textContent = info.description;
23502 document.getElementById("mixed-policy-example").textContent = info.example;
23503 }
23504
23505 function updatePythonDocstringUI() {
23506 var checked = !!pythonDocstrings.checked;
23507 document.getElementById("python-docstring-example").textContent = checked
23508 ? 'def greet():\n """Greet the user.""" \u2190 comment\n print("hi")'
23509 : 'def greet():\n """Greet the user.""" \u2190 not counted\n print("hi")';
23510 document.getElementById("python-docstring-live-help").textContent = checked
23511 ? "Enabled: docstrings contribute to comment-style totals."
23512 : "Disabled: docstrings are not counted as comment content.";
23513 }
23514
23515 function renderPresetChips(targetId, chips) {
23516 var target = document.getElementById(targetId);
23517 if (!target) return;
23518 target.innerHTML = (chips || []).map(function (chip) {
23519 return '<span class="preset-summary-chip">' + escapeHtml(chip) + '</span>';
23520 }).join('');
23521 }
23522
23523 function updatePresetDescriptions() {
23524 var scanInfo = scanPresetInfo[scanPreset.value];
23525 if (!scanInfo) return;
23526 document.getElementById("scan-preset-description").textContent = scanInfo.description;
23527 document.getElementById("scan-preset-example").textContent = scanInfo.example;
23528 document.getElementById("scan-preset-note").textContent = scanInfo.note;
23529 renderPresetChips("scan-preset-summary", scanInfo.chips);
23530 }
23531
23532 function applyScanPreset() {
23533 var info = scanPresetInfo[scanPreset.value];
23534 if (!info || !info.apply) return;
23535 mixedLinePolicy.value = info.apply.mixed;
23536 pythonDocstrings.checked = !!info.apply.docstrings;
23537 document.getElementById("generated_file_detection").value = info.apply.generated;
23538 document.getElementById("minified_file_detection").value = info.apply.minified;
23539 document.getElementById("vendor_directory_detection").value = info.apply.vendor;
23540 document.getElementById("include_lockfiles").value = info.apply.lockfiles;
23541 document.getElementById("binary_file_behavior").value = info.apply.binary;
23542 updateMixedPolicyUI();
23543 updatePythonDocstringUI();
23544 }
23545
23546 function updateReview() {
23547 var scanSummary = document.getElementById("review-scan-summary");
23548 var countSummary = document.getElementById("review-count-summary");
23549 var artifactSummary = document.getElementById("review-artifact-summary");
23550 var outputSummary = document.getElementById("review-output-summary");
23551 var previewSummary = document.getElementById("review-preview-summary");
23552 var readinessSummary = document.getElementById("review-readiness-summary");
23553 var includeText = document.getElementById("include_globs").value.trim();
23554 var excludeText = document.getElementById("exclude_globs").value.trim();
23555 var sidePathPreview = document.getElementById("side-path-preview");
23556 var sideOutputPreview = document.getElementById("side-output-preview");
23557 var sideTitlePreview = document.getElementById("side-title-preview");
23558
23559 if (sidePathPreview) { sidePathPreview.textContent = pathInput.value || "(no path)"; }
23560 if (sideOutputPreview) { sideOutputPreview.textContent = outputDirInput.value || "out/web"; }
23561 if (sideTitlePreview) {
23562 var rt = document.getElementById("report_title");
23563 sideTitlePreview.textContent = (rt && rt.value) ? rt.value : inferTitleFromPath(pathInput.value) || "project";
23564 }
23565
23566 scanSummary.innerHTML = ""
23567 + "<li>Path: " + escapeHtml(pathInput.value || "(no path set)") + "</li>"
23568 + "<li>Include filters: " + escapeHtml(includeText || "none") + "</li>"
23569 + "<li>Exclude filters: " + escapeHtml(excludeText || "none") + "</li>";
23570
23571 countSummary.innerHTML = ""
23572 + "<li>Mixed-line policy: " + escapeHtml(mixedLinePolicy.options[mixedLinePolicy.selectedIndex].text) + "</li>"
23573 + "<li>Python docstrings counted as comments: " + (pythonDocstrings.checked ? "yes" : "no") + "</li>"
23574 + "<li>Generated-file detection: " + escapeHtml(document.getElementById("generated_file_detection").value) + "</li>"
23575 + "<li>Minified-file detection: " + escapeHtml(document.getElementById("minified_file_detection").value) + "</li>"
23576 + "<li>Vendor-directory detection: " + escapeHtml(document.getElementById("vendor_directory_detection").value) + "</li>"
23577 + "<li>Lockfiles: " + escapeHtml(document.getElementById("include_lockfiles").value) + "</li>"
23578 + "<li>Binary behavior: " + escapeHtml(document.getElementById("binary_file_behavior").options[document.getElementById("binary_file_behavior").selectedIndex].text) + "</li>"
23579 + "<li>Scan preset: " + escapeHtml(scanPreset.options[scanPreset.selectedIndex].text) + "</li>"
23580 + "<li>" + attribReviewLine() + "</li>";
23581
23582 artifactSummary.innerHTML = "<li>HTML, PDF, JSON, CSV, XLSX (always generated)</li>";
23583
23584 outputSummary.innerHTML = ""
23585 + "<li>Output directory: " + escapeHtml(outputDirInput.value || "out/web") + "</li>"
23586 + "<li>Report title: " + escapeHtml(reportTitleInput.value || inferTitleFromPath(pathInput.value) || "project") + "</li>";
23587
23588 if (previewSummary) {
23589 if (GIT_MODE) {
23590 previewSummary.innerHTML = '<li class="sx-7f8edb5d" >Scope preview is not pre-computed in git-browser mode \u2014 the repository will be cloned and fully analyzed during the scan run.</li>';
23591 } else {
23592 var statButtons = Array.prototype.slice.call(previewPanel.querySelectorAll('.scope-stat-button'));
23593 var languages = Array.prototype.slice.call(previewPanel.querySelectorAll('.detected-language-chip')).map(function (node) { return node.textContent.trim(); }).filter(Boolean);
23594 var statMap = {};
23595 statButtons.forEach(function (button) {
23596 var valueNode = button.querySelector('.scope-stat-value');
23597 statMap[button.getAttribute('data-filter')] = valueNode ? valueNode.textContent.trim() : '0';
23598 });
23599 previewSummary.innerHTML = ''
23600 + '<li>Directories in preview: ' + escapeHtml(statMap.dir || '0') + '</li>'
23601 + '<li>Files in preview: ' + escapeHtml(statMap.file || '0') + '</li>'
23602 + '<li>Supported files: ' + escapeHtml(statMap.supported || '0') + '</li>'
23603 + '<li>Skipped by policy: ' + escapeHtml(statMap.skipped || '0') + '</li>'
23604 + '<li>Unsupported files: ' + escapeHtml(statMap.unsupported || '0') + '</li>'
23605 + '<li>Detected languages: ' + escapeHtml(languages.join(', ') || 'none') + '</li>';
23606
23607 if (readinessSummary) {
23608 readinessSummary.innerHTML = ''
23609 + '<li>Current step completion: ' + escapeHtml(String(Math.max(0, Math.min(100, (currentStep - 1) * 25)))) + '%</li>'
23610 + '<li>Project path set: ' + (pathInput.value ? 'yes' : 'no') + '</li>'
23611 + '<li>Ready to run: ' + (pathInput.value ? 'yes' : 'no') + '</li>';
23612 }
23613 } // end else (non-GIT_MODE)
23614 }
23615 updateReviewAttribWarn();
23616 }
23617
23618 function escapeHtml(value) {
23619 return String(value)
23620 .replace(/&/g, "&")
23621 .replace(/</g, "<")
23622 .replace(/>/g, ">")
23623 .replace(/"/g, """)
23624 .replace(/'/g, "'");
23625 }
23626
23627 function isPythonVisible() {
23628 return !document.getElementById("python-docstring-wrap").classList.contains("hidden");
23629 }
23630
23631 function syncPythonVisibility() {
23632 var html = previewPanel.textContent || "";
23633 var hasPython = html.indexOf(".py") >= 0 || html.indexOf("Python") >= 0;
23634 pythonWraps.forEach(function (node) {
23635 node.classList.toggle("hidden", !hasPython);
23636 });
23637 }
23638
23639 function attachPreviewInteractions() {
23640 // Multiple-repository caution banner: gate step 1 until acknowledged, and
23641 // let each listed repo be picked as the scan root with one click.
23642 var multiRepoBanner = previewPanel.querySelector(".preview-warning[data-multi-repo]");
23643 if (multiRepoBanner) {
23644 multiRepoBlocked = true;
23645 refreshStep1Gate();
23646 var ackBox = multiRepoBanner.querySelector(".multi-repo-ack");
23647 if (ackBox) {
23648 ackBox.addEventListener("change", function () {
23649 multiRepoBlocked = !ackBox.checked;
23650 refreshStep1Gate();
23651 });
23652 }
23653 var repoButtons = Array.prototype.slice.call(multiRepoBanner.querySelectorAll(".repo-pick"));
23654 repoButtons.forEach(function (btn) {
23655 btn.addEventListener("click", function () {
23656 var repoPath = btn.getAttribute("data-repo-path") || "";
23657 if (!repoPath || !pathInput) return;
23658 pathInput.value = repoPath;
23659 scrollInputToEnd(pathInput);
23660 updateReportTitleFromPath();
23661 autoSetOutputDir(repoPath);
23662 fetchProjectHistory(repoPath);
23663 loadPreview();
23664 updateReview();
23665 });
23666 });
23667 }
23668 var buttons = Array.prototype.slice.call(previewPanel.querySelectorAll(".scope-stat-button"));
23669 var treeContainer = previewPanel.querySelector(".file-explorer-tree");
23670 var rows = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-row"));
23671 var dirRows = rows.filter(function (row) { return row.getAttribute("data-dir") === "true"; });
23672 var filterSelect = previewPanel.querySelector("#explorer-filter-select");
23673 var searchInput = previewPanel.querySelector("#explorer-search");
23674 var actionButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".explorer-action"));
23675 var sortButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".tree-sort-button"));
23676 var languageButtons = Array.prototype.slice.call(previewPanel.querySelectorAll(".detected-language-chip"));
23677 var activeFilter = "all";
23678 var activeLanguage = "";
23679 var searchTerm = "";
23680 var currentSortKey = null;
23681 var currentSortOrder = "asc";
23682 var childRows = {};
23683
23684 rows.forEach(function (row) {
23685 var parentId = row.getAttribute("data-parent-id") || "";
23686 var rowId = row.getAttribute("data-row-id") || "";
23687 if (!childRows[parentId]) childRows[parentId] = [];
23688 childRows[parentId].push(rowId);
23689 });
23690
23691 function rowById(id) {
23692 return previewPanel.querySelector('.tree-row[data-row-id="' + id + '"]');
23693 }
23694
23695 function hasCollapsedAncestor(row) {
23696 var parentId = row.getAttribute("data-parent-id");
23697 while (parentId) {
23698 var parent = rowById(parentId);
23699 if (!parent) break;
23700 if (parent.getAttribute("data-expanded") === "false") return true;
23701 parentId = parent.getAttribute("data-parent-id");
23702 }
23703 return false;
23704 }
23705
23706 function updateToggleGlyph(row) {
23707 var toggle = row.querySelector(".tree-toggle");
23708 if (!toggle) return;
23709 toggle.textContent = row.getAttribute("data-expanded") === "false" ? "\u25b8" : "\u25be";
23710 }
23711
23712 function rowSortValue(row, key) {
23713 return (row.getAttribute("data-sort-" + key) || "").toLowerCase();
23714 }
23715
23716 function updateSortButtons() {
23717 sortButtons.forEach(function (button) {
23718 var isActive = button.getAttribute("data-sort-key") === currentSortKey;
23719 var indicator = button.querySelector(".tree-sort-indicator");
23720 button.classList.toggle("active", isActive);
23721 button.setAttribute("data-sort-order", isActive ? currentSortOrder : "none");
23722 if (indicator) {
23723 indicator.textContent = !isActive ? "\u2195" : (currentSortOrder === "asc" ? "\u2191" : "\u2193");
23724 }
23725 });
23726 }
23727
23728 function sortSiblingRows() {
23729 if (!treeContainer) {
23730 updateSortButtons();
23731 return;
23732 }
23733
23734 var rowMap = {};
23735 var childrenMap = {};
23736 rows.forEach(function (row) {
23737 var rowId = row.getAttribute("data-row-id");
23738 var parentId = row.getAttribute("data-parent-id") || "";
23739 rowMap[rowId] = row;
23740 if (!childrenMap[parentId]) childrenMap[parentId] = [];
23741 childrenMap[parentId].push(rowId);
23742 });
23743
23744 Object.keys(childrenMap).forEach(function (parentId) {
23745 if (!parentId) return;
23746 childrenMap[parentId].sort(function (a, b) {
23747 var rowA = rowMap[a];
23748 var rowB = rowMap[b];
23749 if (!currentSortKey) {
23750 return Number(a) - Number(b);
23751 }
23752 var valueA = rowSortValue(rowA, currentSortKey);
23753 var valueB = rowSortValue(rowB, currentSortKey);
23754 if (valueA < valueB) return currentSortOrder === "asc" ? -1 : 1;
23755 if (valueA > valueB) return currentSortOrder === "asc" ? 1 : -1;
23756 var fallbackA = rowSortValue(rowA, "name");
23757 var fallbackB = rowSortValue(rowB, "name");
23758 if (fallbackA < fallbackB) return -1;
23759 if (fallbackA > fallbackB) return 1;
23760 return Number(a) - Number(b);
23761 });
23762 });
23763
23764 var orderedIds = [];
23765 function pushChildren(parentId) {
23766 (childrenMap[parentId] || []).forEach(function (childId) {
23767 orderedIds.push(childId);
23768 pushChildren(childId);
23769 });
23770 }
23771
23772 (childrenMap[""] || []).sort(function (a, b) { return Number(a) - Number(b); }).forEach(function (topId) {
23773 orderedIds.push(topId);
23774 pushChildren(topId);
23775 });
23776
23777 orderedIds.forEach(function (id) {
23778 if (rowMap[id]) treeContainer.appendChild(rowMap[id]);
23779 });
23780 updateSortButtons();
23781 }
23782
23783 function updateLanguageButtons() {
23784 languageButtons.forEach(function (button) {
23785 var languageValue = (button.getAttribute("data-language-filter") || "").toLowerCase();
23786 var isActive = languageValue === activeLanguage;
23787 button.classList.toggle("active", isActive);
23788 });
23789 }
23790
23791 function rowSelfMatches(row) {
23792 var kind = row.getAttribute("data-kind");
23793 var status = row.getAttribute("data-status");
23794 var language = (row.getAttribute("data-language") || "").toLowerCase();
23795 var name = row.getAttribute("data-name-lower") || "";
23796 var type = (row.querySelector('.tree-type-cell') || { textContent: '' }).textContent.toLowerCase();
23797 var passesFilter = activeFilter === "all" || (activeFilter === "file" && kind === "file") || (activeFilter === "dir" && kind === "dir") || activeFilter === status;
23798 var passesSearch = !searchTerm || name.indexOf(searchTerm) >= 0 || type.indexOf(searchTerm) >= 0 || status.indexOf(searchTerm) >= 0 || language.indexOf(searchTerm) >= 0;
23799 var passesLanguage = !activeLanguage || language === activeLanguage;
23800 return passesFilter && passesSearch && passesLanguage;
23801 }
23802
23803 function hasMatchingDescendant(rowId) {
23804 return (childRows[rowId] || []).some(function (childId) {
23805 var childRow = rowById(childId);
23806 return !!childRow && (rowSelfMatches(childRow) || hasMatchingDescendant(childId));
23807 });
23808 }
23809
23810 function rowMatches(row) {
23811 if (rowSelfMatches(row)) return true;
23812 return row.getAttribute("data-dir") === "true" && hasMatchingDescendant(row.getAttribute("data-row-id") || "");
23813 }
23814
23815 function resetViewState() {
23816 activeFilter = "all";
23817 activeLanguage = "";
23818 searchTerm = "";
23819 currentSortKey = null;
23820 currentSortOrder = "asc";
23821 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
23822 if (searchInput) searchInput.value = "";
23823 if (filterSelect) filterSelect.value = "all";
23824 updateLanguageButtons();
23825 }
23826
23827 function applyVisibility() {
23828 rows.forEach(function (row) {
23829 var visible = rowMatches(row) && !hasCollapsedAncestor(row);
23830 row.classList.toggle("hidden-by-filter", !visible);
23831 row.style.display = visible ? "grid" : "none";
23832 });
23833 buttons.forEach(function (button) {
23834 button.classList.toggle("active", button.getAttribute("data-filter") === activeFilter);
23835 });
23836 if (filterSelect) filterSelect.value = activeFilter;
23837 }
23838
23839 var submoduleChips = Array.prototype.slice.call(previewPanel.querySelectorAll('.submodule-preview-chip[data-sub-stats]'));
23840 var baseRepoBtn = previewPanel.querySelector('.submodule-base-repo-btn');
23841 var originalStats = {};
23842 buttons.forEach(function (btn) {
23843 var f = btn.getAttribute('data-filter');
23844 var v = btn.querySelector('.scope-stat-value');
23845 if (f && v) originalStats[f] = v.textContent;
23846 });
23847
23848 function applySubmoduleStats(statsJson) {
23849 try {
23850 var s = JSON.parse(statsJson);
23851 buttons.forEach(function (btn) {
23852 var f = btn.getAttribute('data-filter');
23853 var v = btn.querySelector('.scope-stat-value');
23854 if (!v) return;
23855 if (f === 'dir') v.textContent = s.dirs;
23856 else if (f === 'file') v.textContent = s.files;
23857 else if (f === 'supported') v.textContent = s.supported;
23858 else if (f === 'skipped') v.textContent = s.skipped;
23859 else if (f === 'unsupported') v.textContent = s.unsupported;
23860 });
23861 } catch (e) {}
23862 }
23863
23864 function restoreBaseRepoStats() {
23865 buttons.forEach(function (btn) {
23866 var f = btn.getAttribute('data-filter');
23867 var v = btn.querySelector('.scope-stat-value');
23868 if (v && originalStats[f]) v.textContent = originalStats[f];
23869 });
23870 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
23871 if (baseRepoBtn) baseRepoBtn.style.display = 'none';
23872 }
23873
23874 submoduleChips.forEach(function (chip) {
23875 chip.addEventListener('click', function () {
23876 var statsJson = chip.getAttribute('data-sub-stats');
23877 if (!statsJson) return;
23878 submoduleChips.forEach(function (c) { c.classList.remove('active'); });
23879 chip.classList.add('active');
23880 applySubmoduleStats(statsJson);
23881 if (baseRepoBtn) baseRepoBtn.style.display = '';
23882 });
23883 });
23884
23885 if (baseRepoBtn) {
23886 baseRepoBtn.addEventListener('click', function () {
23887 restoreBaseRepoStats();
23888 resetViewState();
23889 sortSiblingRows();
23890 applyVisibility();
23891 });
23892 }
23893
23894 buttons.forEach(function (button) {
23895 button.addEventListener("click", function () {
23896 var filterValue = button.getAttribute("data-filter") || "all";
23897 if (filterValue === "reset-view") {
23898 restoreBaseRepoStats();
23899 resetViewState();
23900 sortSiblingRows();
23901 applyVisibility();
23902 return;
23903 }
23904 activeFilter = filterValue;
23905 applyVisibility();
23906 });
23907 });
23908
23909 rows.forEach(function (row) {
23910 updateToggleGlyph(row);
23911 var toggle = row.querySelector(".tree-toggle");
23912 if (toggle) {
23913 toggle.addEventListener("click", function () {
23914 var expanded = row.getAttribute("data-expanded") !== "false";
23915 row.setAttribute("data-expanded", expanded ? "false" : "true");
23916 updateToggleGlyph(row);
23917 applyVisibility();
23918 });
23919 }
23920 });
23921
23922 actionButtons.forEach(function (button) {
23923 button.addEventListener("click", function () {
23924 var action = button.getAttribute("data-explorer-action");
23925 if (action === "expand-all") {
23926 dirRows.forEach(function (row) { row.setAttribute("data-expanded", "true"); updateToggleGlyph(row); });
23927 } else if (action === "collapse-all") {
23928 dirRows.forEach(function (row, index) { row.setAttribute("data-expanded", index === 0 ? "true" : "false"); updateToggleGlyph(row); });
23929 } else if (action === "clear-filters") {
23930 resetViewState();
23931 }
23932 sortSiblingRows();
23933 applyVisibility();
23934 });
23935 });
23936
23937 if (filterSelect) {
23938 filterSelect.addEventListener("change", function () {
23939 activeFilter = filterSelect.value || "all";
23940 applyVisibility();
23941 });
23942 }
23943
23944 languageButtons.forEach(function (button) {
23945 button.addEventListener("click", function () {
23946 activeLanguage = (button.getAttribute("data-language-filter") || "").toLowerCase();
23947 updateLanguageButtons();
23948 applyVisibility();
23949 });
23950 });
23951
23952 sortButtons.forEach(function (button) {
23953 button.addEventListener("click", function () {
23954 var sortKey = button.getAttribute("data-sort-key");
23955 if (currentSortKey === sortKey) {
23956 currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
23957 } else {
23958 currentSortKey = sortKey;
23959 currentSortOrder = "asc";
23960 }
23961 sortSiblingRows();
23962 applyVisibility();
23963 });
23964 });
23965
23966 if (searchInput) {
23967 searchInput.addEventListener("input", function () {
23968 searchTerm = searchInput.value.trim().toLowerCase();
23969 applyVisibility();
23970 });
23971 }
23972
23973 updateLanguageButtons();
23974 sortSiblingRows();
23975 applyVisibility();
23976 }
23977
23978 // ── Attribution (git blame) cost estimate ────────────────────────────────
23979 // Once the user manually changes the Code-ownership select we stop auto-defaulting it, so an
23980 // explicit choice is never overridden by a later estimate.
23981 var attributionTouched = false;
23982 var _attribEstGen = 0;
23983 var lastAttribEstimate = null; // most recent /api/attribution-estimate result for this path
23984 (function() {
23985 var sel = document.getElementById('attribution');
23986 if (sel) sel.addEventListener('change', function() {
23987 attributionTouched = true;
23988 // Re-render both the step-2 note and the step-4 review warning to reflect the new choice.
23989 renderAttribBanner();
23990 updateReviewAttribWarn();
23991 });
23992 }());
23993 function estFmt(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();}
23994 // Render the step-2 attribution note from the last estimate + the current toggle state.
23995 function renderAttribBanner() {
23996 var box = document.getElementById('attrib-estimate');
23997 var sel = document.getElementById('attribution');
23998 if (!box || !sel) return;
23999 var d = lastAttribEstimate;
24000 if (!d || !d.is_git || !d.blameable_files) { box.className = 'attrib-estimate hidden'; box.textContent = ''; return; }
24001 var files = estFmt(d.blameable_files), commits = estFmt(d.commit_count), dur = d.estimated_label || '';
24002 var sev = d.severity || 'light';
24003 var on = sel.value !== 'disabled';
24004 box.className = 'attrib-estimate est-' + sev;
24005 if (sev === 'heavy') {
24006 box.innerHTML = on
24007 ? '<b>Heads up — attribution is ON for a very large repo.</b> Blaming ' + files +
24008 ' files across ~' + commits + ' commits will make this scan take roughly <b>' + dur +
24009 '</b> longer. Turn it off here for a fast scan.'
24010 : '<b>Large history detected.</b> Attributing ' + files + ' files (~' + commits +
24011 ' commits) would take about <b>' + dur + '</b>, so per-author attribution has been turned <b>off</b>. Switch it on above to run it anyway.';
24012 } else if (sev === 'moderate') {
24013 box.innerHTML = (on ? 'With attribution on, this scan blames ' : 'Attribution (currently off) would blame ') +
24014 '<b>' + files + '</b> files (~' + commits + ' commits) and add about <b>' + dur + '</b>.';
24015 } else {
24016 box.innerHTML = 'Per-author attribution is quick here — about <b>' + (dur || '~1s') +
24017 '</b> for <b>' + files + '</b> files.';
24018 }
24019 }
24020 // Prominent step-4 (review) warning: only shown when attribution is ON and non-trivial.
24021 function updateReviewAttribWarn() {
24022 var warn = document.getElementById('review-attrib-warn');
24023 var sel = document.getElementById('attribution');
24024 if (!warn || !sel) return;
24025 var d = lastAttribEstimate;
24026 var on = sel.value !== 'disabled';
24027 if (!d || !d.is_git || !d.blameable_files || !on || (d.severity || 'light') === 'light') {
24028 warn.className = 'review-attrib-warn hidden'; warn.textContent = ''; return;
24029 }
24030 var files = estFmt(d.blameable_files), commits = estFmt(d.commit_count), dur = d.estimated_label || '';
24031 warn.className = 'review-attrib-warn ' + (d.severity === 'heavy' ? 'raw-heavy' : 'raw-moderate');
24032 warn.innerHTML = '<span><b>Per-author attribution is ON.</b> This scan will run <code>git blame</code> on <b>' +
24033 files + '</b> files across ~' + commits + ' commits, making it take roughly <b>' + dur +
24034 '</b> longer than a scan without it. To skip it, go back to step 2 (How it will be counted) and set Code ownership to Off.</span>';
24035 }
24036 // One-line attribution summary for the step-4 "How it will be counted" card.
24037 function attribReviewLine() {
24038 var sel = document.getElementById('attribution');
24039 var on = sel && sel.value !== 'disabled';
24040 var d = lastAttribEstimate;
24041 var suffix = '';
24042 if (d && d.is_git && d.blameable_files) {
24043 suffix = ' (' + estFmt(d.blameable_files) + ' files'
24044 + (on && d.estimated_label ? ', ~' + d.estimated_label : '') + ')';
24045 }
24046 return 'Per-author attribution (git blame): ' + (on ? 'on' : 'off') + suffix;
24047 }
24048 // Populate the "super-repo vs. with-submodules" commit-count display next to project size,
24049 // plus the current-branch chip that sits between project size and the commit counts.
24050 function updateCommitCounts(d) {
24051 var bBox = document.getElementById('git-branch-box');
24052 var bName = document.getElementById('git-branch-name');
24053 if (bBox) {
24054 if (d && d.is_git && d.branch) {
24055 if (bName) bName.textContent = d.branch;
24056 bBox.classList.remove('hidden');
24057 } else {
24058 bBox.classList.add('hidden');
24059 }
24060 }
24061 var box = document.getElementById('commit-counts');
24062 if (!box) return;
24063 if (!d || !d.is_git || !d.commit_count) { box.className = 'commit-counts hidden'; return; }
24064 var sup = document.getElementById('cc-super');
24065 var comb = document.getElementById('cc-combined');
24066 var combItem = document.getElementById('cc-combined-item');
24067 if (sup) sup.textContent = estFmt(d.commit_count);
24068 if (comb) comb.textContent = estFmt(d.combined_commit_count || d.commit_count);
24069 // The combined column is only meaningful when the repo actually has submodules.
24070 if (combItem) combItem.style.display = (d.submodule_count > 0) ? '' : 'none';
24071 box.className = 'commit-counts';
24072 }
24073 function updateAttribEstimate(path) {
24074 var sel = document.getElementById('attribution');
24075 if (!document.getElementById('attrib-estimate') || !sel) return;
24076 if (!path) { lastAttribEstimate = null; renderAttribBanner(); updateReviewAttribWarn(); updateCommitCounts(null); return; }
24077 var myGen = ++_attribEstGen;
24078 fetch('/api/attribution-estimate?path=' + encodeURIComponent(path))
24079 .then(function(r) { return r.json(); })
24080 .then(function(d) {
24081 if (myGen !== _attribEstGen) return; // a newer path won the race
24082 lastAttribEstimate = d;
24083 // Auto-default the toggle once (unless the user already chose): off only when heavy.
24084 if (d && d.is_git && d.blameable_files && !attributionTouched) {
24085 sel.value = (d.severity === 'heavy') ? 'disabled' : 'enabled';
24086 }
24087 renderAttribBanner();
24088 updateReviewAttribWarn();
24089 updateCommitCounts(d);
24090 })
24091 .catch(function() { if (myGen === _attribEstGen) { lastAttribEstimate = null; renderAttribBanner(); updateReviewAttribWarn(); updateCommitCounts(null); } });
24092 }
24093
24094 function loadPreview() {
24095 if (!previewPanel || !pathInput) return;
24096 // A fresh preview re-establishes the multi-repo gate; clear any prior ack.
24097 multiRepoBlocked = false;
24098 refreshStep1Gate();
24099 if (GIT_MODE) {
24100 previewPanel.innerHTML = '<div class="preview-error sx-f3d78751" >Preview is not available for remote git refs. The scan will check out the source at runtime.</div>';
24101 setPreviewLoading(false);
24102 return;
24103 }
24104 var path = pathInput.value.trim();
24105 var zeroWarn = document.getElementById('zero-files-warning');
24106 if (!path) {
24107 previewPanel.innerHTML = '<div class="preview-hint">Enter a project path above to preview the files that will be in scope.</div>';
24108 if (zeroWarn) zeroWarn.style.display = 'none';
24109 setPreviewLoading(false);
24110 return;
24111 }
24112 var includeValue = includeGlobsInput ? includeGlobsInput.value : "";
24113 var excludeValue = excludeGlobsInput ? excludeGlobsInput.value : "";
24114 if (window._previewInterval) { clearInterval(window._previewInterval); window._previewInterval = null; }
24115 if (window._previewElapsedTimer) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; }
24116 var myGen = ++_previewGen;
24117 var _prevMsgs = [
24118 'Scanning directory structure\u2026',
24119 'Detecting file types\u2026',
24120 'Applying include / exclude filters\u2026',
24121 'Estimating file counts\u2026',
24122 'Building scope preview\u2026',
24123 'Almost there\u2026'
24124 ];
24125 var _prevMsgIdx = 0;
24126 var _prevStart = Date.now();
24127 previewPanel.innerHTML =
24128 '<div class="preview-loading">' +
24129 '<div class="preview-spinner"></div>' +
24130 '<div class="preview-loading-text">' +
24131 '<div class="preview-loading-msg" id="plm">' + _prevMsgs[0] + '</div>' +
24132 '<div class="preview-loading-elapsed" id="ple">0s elapsed</div>' +
24133 '</div></div>';
24134 var _sizeTextEl = document.getElementById('project-size-text');
24135 if (_sizeTextEl) _sizeTextEl.textContent = 'Project size: Detecting\u2026';
24136 window._previewInterval = setInterval(function() {
24137 if (myGen !== _previewGen) { clearInterval(window._previewInterval); window._previewInterval = null; return; }
24138 _prevMsgIdx = (_prevMsgIdx + 1) % _prevMsgs.length;
24139 var ml = document.getElementById('plm');
24140 if (ml) ml.textContent = _prevMsgs[_prevMsgIdx];
24141 }, 1500);
24142 window._previewElapsedTimer = setInterval(function() {
24143 if (myGen !== _previewGen) { clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null; return; }
24144 var el = document.getElementById('ple');
24145 if (el) el.textContent = Math.round((Date.now() - _prevStart) / 1000) + 's elapsed';
24146 }, 1000);
24147 setPreviewLoading(true);
24148 // Kick off the (independent) attribution cost estimate for this path in parallel with the
24149 // scope preview; it auto-tunes the Code-ownership default on very large repos.
24150 updateAttribEstimate(path);
24151 var previewUrl = "/preview?path=" + encodeURIComponent(path)
24152 + "&include_globs=" + encodeURIComponent(includeValue)
24153 + "&exclude_globs=" + encodeURIComponent(excludeValue);
24154 fetch(previewUrl)
24155 .then(function (response) { return response.text(); })
24156 .then(function (html) {
24157 if (myGen !== _previewGen) return;
24158 clearInterval(window._previewInterval); window._previewInterval = null;
24159 clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
24160 setPreviewLoading(false);
24161 previewPanel.innerHTML = html;
24162 attachPreviewInteractions();
24163 syncPythonVisibility();
24164 updateReview();
24165 setTimeout(collapseLanguagePills, 50);
24166 var explorerWrap = previewPanel.querySelector('.explorer-wrap');
24167 var projectSize = explorerWrap ? explorerWrap.getAttribute('data-project-size') : null;
24168 var sizeText = document.getElementById('project-size-text');
24169 var sizeBtn = document.getElementById('project-size-btn');
24170 // In server mode with upload sizes available, keep the compressed/original pair.
24171 if (SERVER_MODE && window._lastUploadSizes) {
24172 var us = window._lastUploadSizes;
24173 if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(us.original_bytes) +
24174 ' \xb7 Compressed: ' + fmtBytes(us.compressed_bytes);
24175 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(us.original_bytes) +
24176 ' \u2014 Compressed archive size: ' + fmtBytes(us.compressed_bytes);
24177 } else if (sizeText && projectSize) {
24178 sizeText.textContent = 'Project size: ' + projectSize;
24179 if (sizeBtn) sizeBtn.title = 'Total disk size of the selected project directory: ' + projectSize;
24180 } else if (sizeText) {
24181 sizeText.textContent = 'Project size: \u2014';
24182 }
24183 if (zeroWarn) {
24184 var supportedBtn = previewPanel.querySelector('.scope-stat-button.supported .scope-stat-value');
24185 var filesBtn = previewPanel.querySelector('.scope-stat-button[data-filter="file"] .scope-stat-value');
24186 var supportedCount = supportedBtn ? parseInt(supportedBtn.textContent, 10) : -1;
24187 var fileCount = filesBtn ? parseInt(filesBtn.textContent, 10) : -1;
24188 if (supportedCount === 0 && fileCount > 0) {
24189 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).';
24190 zeroWarn.style.display = '';
24191 } else {
24192 zeroWarn.style.display = 'none';
24193 }
24194 }
24195 })
24196 .catch(function (err) {
24197 if (myGen !== _previewGen) return;
24198 clearInterval(window._previewInterval); window._previewInterval = null;
24199 clearInterval(window._previewElapsedTimer); window._previewElapsedTimer = null;
24200 setPreviewLoading(false);
24201 previewPanel.innerHTML = '<div class="preview-error">Preview request failed: ' + String(err) + '</div>';
24202 });
24203 }
24204
24205 function pickDirectory(targetInput, kind) {
24206 if (!targetInput) {
24207 showBannerToast("Directory picker: input element not found.", true);
24208 return;
24209 }
24210 if (SERVER_MODE) {
24211 if (kind === 'output') {
24212 showBannerToast(
24213 'Server mode: type the output path directly into the field \u2014 the path must exist on the server, not your local machine.',
24214 false,
24215 { top: true, icon: '\u{1F4C1}' }
24216 );
24217 return;
24218 }
24219 var inputEl = kind === 'coverage'
24220 ? document.getElementById('cov-upload-input')
24221 : document.getElementById('dir-upload-input');
24222 if (!inputEl) return;
24223 inputEl.onchange = function () {
24224 var files = inputEl.files;
24225 if (!files || files.length === 0) return;
24226 var browseBtn = targetInput === pathInput ? browsePath : browseOutputDir;
24227 if (browseBtn) browseBtn.disabled = true;
24228
24229 function fileToBase64(file) {
24230 return new Promise(function (resolve, reject) {
24231 var reader = new FileReader();
24232 reader.onload = function () {
24233 var b64 = reader.result.split(',')[1];
24234 resolve(b64);
24235 };
24236 reader.onerror = reject;
24237 reader.readAsDataURL(file);
24238 });
24239 }
24240
24241 if (kind === 'coverage') {
24242 var f = files[0];
24243 if (previewPanel && targetInput === pathInput)
24244 previewPanel.innerHTML = '<div class="preview-error">Uploading coverage file\u2026</div>';
24245 fileToBase64(f).then(function (b64) {
24246 return fetch('/api/upload-file', {
24247 method: 'POST',
24248 headers: { 'Content-Type': 'application/json' },
24249 body: JSON.stringify({ filename: f.name, content: b64 })
24250 }).then(function (r) { return r.json(); });
24251 })
24252 .then(function (d) {
24253 if (d && d.tmp_path) {
24254 if (coverageInput) coverageInput.value = d.tmp_path;
24255 setCovStatus('idle');
24256 } else if (d && d.error) { showBannerToast(d.error, true); }
24257 })
24258 .catch(function (e) { showBannerToast('Upload failed: ' + String(e), true); })
24259 .finally(function () { if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; });
24260 } else {
24261 // ── Filter to source-code files only ─────────────────────────
24262 // Binary, generated, and dependency files (node_modules, .git,
24263 // build artifacts) are skipped so they are never uploaded.
24264 var CODE_EXTS = new Set([
24265 'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
24266 'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
24267 'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
24268 'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
24269 'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
24270 'asm','s','S','objc','lisp','el','rkt','ml','mli','ocaml','v','sv','vhd','vhdl',
24271 'tf','hcl','proto','thrift','avsc','graphql','gql'
24272 ]);
24273 var codeFiles = [];
24274 for (var i = 0; i < files.length; i++) {
24275 var f = files[i];
24276 var name = f.name;
24277 if (name === 'Makefile' || name === 'Dockerfile' || name === 'Gemfile' ||
24278 name === 'Rakefile' || name === 'Procfile' || name === 'Justfile') {
24279 codeFiles.push(f); continue;
24280 }
24281 var dot = name.lastIndexOf('.');
24282 if (dot >= 0 && CODE_EXTS.has(name.slice(dot + 1).toLowerCase())) codeFiles.push(f);
24283 }
24284 // Collect specific .git metadata files for server-side git detection.
24285 // These have no source extension so they are excluded by the loop above,
24286 // but the server needs them to read branch/commit/author without running git.
24287 var gitMetaFiles = [];
24288 for (var i = 0; i < files.length; i++) {
24289 var f = files[i];
24290 var rp = (f.webkitRelativePath || '').replace(/\\/g, '/');
24291 var gitIdx = rp.indexOf('/.git/');
24292 if (gitIdx < 0) continue;
24293 var gitRel = rp.slice(gitIdx + 1);
24294 if (gitRel === '.git/HEAD' || gitRel === '.git/packed-refs' ||
24295 gitRel === '.git/logs/HEAD' ||
24296 gitRel.startsWith('.git/refs/heads/') ||
24297 gitRel.startsWith('.git/refs/tags/')) {
24298 gitMetaFiles.push(f);
24299 }
24300 }
24301 var uploadFiles = codeFiles.concat(gitMetaFiles);
24302 var total = files.length;
24303 var kept = codeFiles.length;
24304 if (kept === 0) {
24305 if (previewPanel && targetInput === pathInput)
24306 previewPanel.innerHTML = '<div class="preview-error">No supported source files found in the selected folder (' + total.toLocaleString() + ' files scanned).</div>';
24307 if (browseBtn) browseBtn.disabled = false;
24308 inputEl.value = '';
24309 return;
24310 }
24311
24312 // ── Helper: apply upload result to UI ────────────────────────
24313 // sizes = {compressed_bytes, original_bytes} from the server response (server mode only).
24314 function applyUploadResult(tmpPath, sizes) {
24315 targetInput.value = tmpPath;
24316 scrollInputToEnd(targetInput);
24317 if (sizes && SERVER_MODE) {
24318 window._lastUploadSizes = sizes;
24319 // Immediately show both sizes before preview loads.
24320 var sizeText = document.getElementById('project-size-text');
24321 var sizeBtn = document.getElementById('project-size-btn');
24322 if (sizeText) {
24323 sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
24324 ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
24325 }
24326 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
24327 ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
24328 }
24329 if (targetInput === pathInput) {
24330 updateReportTitleFromPath();
24331 autoSetOutputDir(tmpPath);
24332 fetchProjectHistory(tmpPath);
24333 loadPreview();
24334 suggestCoverageFile(tmpPath);
24335 }
24336 updateReview();
24337 if (browseBtn) browseBtn.disabled = false;
24338 inputEl.value = '';
24339 }
24340
24341 // ── Path A: tar.gz via native CompressionStream (Chrome 80+, FF 113+, Safari 16.4+)
24342 if (typeof CompressionStream !== 'undefined') {
24343 if (previewPanel && targetInput === pathInput)
24344 previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
24345
24346 // Build a minimal POSIX ustar tar header for a single file entry.
24347 function buildUstarHeader(filePath, fileSize) {
24348 var BLOCK = 512;
24349 var hdr = new Uint8Array(BLOCK);
24350 var enc = new TextEncoder();
24351 function wStr(off, len, s) {
24352 var b = enc.encode(s);
24353 for (var i = 0; i < Math.min(b.length, len); i++) hdr[off + i] = b[i];
24354 }
24355 function wOct(off, len, val) {
24356 var s = val.toString(8);
24357 while (s.length < len - 1) s = '0' + s;
24358 wStr(off, len, s + '\0');
24359 }
24360 // Long-path split: ustar name ≤99 chars, prefix ≤154 chars.
24361 var name = filePath, prefix = '';
24362 if (filePath.length > 99) {
24363 var split = filePath.lastIndexOf('/', 154);
24364 if (split > 0 && filePath.length - split - 1 <= 99) {
24365 prefix = filePath.substring(0, split);
24366 name = filePath.substring(split + 1);
24367 } else { name = filePath.substring(0, 99); }
24368 }
24369 wStr(0, 100, name); // name
24370 wOct(100, 8, 0o000644); // mode
24371 wOct(108, 8, 0); // uid
24372 wOct(116, 8, 0); // gid
24373 wOct(124, 12, fileSize); // size
24374 wOct(136, 12, 0); // mtime (epoch)
24375 for (var i = 148; i < 156; i++) hdr[i] = 32; // checksum placeholder = spaces
24376 hdr[156] = 48; // type flag '0' = regular file
24377 wStr(157, 100, ''); // linkname
24378 wStr(257, 6, 'ustar'); // magic
24379 wStr(263, 2, '00'); // version
24380 wStr(265, 32, ''); // uname
24381 wStr(297, 32, ''); // gname
24382 wOct(329, 8, 0); // devmajor
24383 wOct(337, 8, 0); // devminor
24384 wStr(345, 155, prefix); // prefix
24385 // Compute checksum (sum of all bytes, placeholder = 32).
24386 var chk = 0;
24387 for (var i = 0; i < BLOCK; i++) chk += hdr[i];
24388 var cs = chk.toString(8);
24389 while (cs.length < 6) cs = '0' + cs;
24390 wStr(148, 8, cs + '\0 ');
24391 return hdr;
24392 }
24393
24394 // Build tar.gz one file at a time, piping through CompressionStream.
24395 // RAM usage = compressed output buffer + one file at a time.
24396 (async function () {
24397 try {
24398 var BLOCK = 512;
24399 var cs = new CompressionStream('gzip');
24400 var writer = cs.writable.getWriter();
24401 var chunks = [];
24402 var reader = cs.readable.getReader();
24403 var collecting = (async function () {
24404 while (true) { var r = await reader.read(); if (r.done) break; chunks.push(r.value); }
24405 })();
24406
24407 for (var i = 0; i < uploadFiles.length; i++) {
24408 var file = uploadFiles[i];
24409 var path = file.webkitRelativePath || file.name;
24410 var buf = await file.arrayBuffer();
24411 var data = new Uint8Array(buf);
24412 // Header block
24413 await writer.write(buildUstarHeader(path, data.length));
24414 // Data padded to 512-byte boundary
24415 if (data.length > 0) {
24416 var padded = Math.ceil(data.length / BLOCK) * BLOCK;
24417 var block = new Uint8Array(padded);
24418 block.set(data);
24419 await writer.write(block);
24420 }
24421 if ((i + 1) % 50 === 0 || i === uploadFiles.length - 1) {
24422 if (previewPanel && targetInput === pathInput)
24423 previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i + 1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
24424 }
24425 }
24426 // End-of-archive: two 512-byte zero blocks
24427 await writer.write(new Uint8Array(BLOCK * 2));
24428 await writer.close();
24429 await collecting;
24430
24431 var blob = new Blob(chunks, { type: 'application/gzip' });
24432 var sizeMB = (blob.size / 1048576).toFixed(1);
24433 if (previewPanel && targetInput === pathInput)
24434 previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + (total !== kept ? kept.toLocaleString() + ' of ' + total.toLocaleString() + ' files' : kept.toLocaleString() + ' files') + ')\u2026</div>';
24435
24436 var resp = await fetch('/api/upload-tarball', {
24437 method: 'POST',
24438 headers: { 'Content-Type': 'application/gzip' },
24439 body: blob
24440 });
24441 var d = await resp.json();
24442 if (d && d.tmp_path) {
24443 applyUploadResult(d.tmp_path, {
24444 compressed_bytes: d.compressed_bytes || 0,
24445 original_bytes: d.original_bytes || 0
24446 });
24447 } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
24448 } catch (e) {
24449 showBannerToast('Upload failed: ' + String(e), true);
24450 if (browseBtn) browseBtn.disabled = false;
24451 inputEl.value = '';
24452 }
24453 })();
24454
24455 } else {
24456 // ── Path B: Legacy fallback — sequential JSON+base64 batches ─
24457 // Used only on browsers that lack CompressionStream (pre-2023).
24458 var BATCH = 200;
24459 var batches = [];
24460 for (var b = 0; b < uploadFiles.length; b += BATCH) batches.push(uploadFiles.slice(b, b + BATCH));
24461 var totalBatches = batches.length;
24462 if (previewPanel && targetInput === pathInput)
24463 previewPanel.innerHTML = '<div class="preview-error">Uploading ' + kept.toLocaleString() + ' code file' + (kept === 1 ? '' : 's') + (total !== kept ? ' of ' + total.toLocaleString() + ' total' : '') + '\u2026</div>';
24464
24465 function sendBatch(idx, currentUploadId, lastTmpPath) {
24466 if (idx >= totalBatches) { applyUploadResult(lastTmpPath); return; }
24467 if (previewPanel && targetInput === pathInput && totalBatches > 1)
24468 previewPanel.innerHTML = '<div class="preview-error">Uploading batch ' + (idx + 1) + ' of ' + totalBatches + '\u2026</div>';
24469 Promise.all(batches[idx].map(function (file) {
24470 return fileToBase64(file).then(function (b64) {
24471 return { path: file.webkitRelativePath || file.name, content: b64 };
24472 });
24473 })).then(function (fileList) {
24474 var body = { files: fileList };
24475 if (currentUploadId) body.upload_id = currentUploadId;
24476 return fetch('/api/upload-directory', {
24477 method: 'POST', headers: { 'Content-Type': 'application/json' },
24478 body: JSON.stringify(body)
24479 }).then(function (r) { return r.json(); });
24480 }).then(function (d) {
24481 if (d && d.tmp_path) sendBatch(idx + 1, d.upload_id || currentUploadId, d.tmp_path);
24482 else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (browseBtn) browseBtn.disabled = false; inputEl.value = ''; }
24483 }).catch(function (e) {
24484 showBannerToast('Upload failed: ' + String(e), true);
24485 if (browseBtn) browseBtn.disabled = false; inputEl.value = '';
24486 });
24487 }
24488 sendBatch(0, null, '');
24489 }
24490 }
24491 };
24492 inputEl.click();
24493 return;
24494 }
24495
24496 var browseButton = targetInput === pathInput ? browsePath : browseOutputDir;
24497 if (browseButton) browseButton.disabled = true;
24498
24499 if (previewPanel && targetInput === pathInput) {
24500 previewPanel.innerHTML = '<div class="preview-error">Opening folder picker...</div>';
24501 }
24502
24503 fetch("/pick-directory?kind=" + encodeURIComponent(kind || "project") + "¤t=" + encodeURIComponent(targetInput.value || ""))
24504 .then(function (response) { return response.ok ? response.json() : { cancelled: true }; })
24505 .then(function (data) {
24506 if (data && data.selected_path) {
24507 targetInput.value = data.selected_path;
24508 scrollInputToEnd(targetInput);
24509
24510 if (targetInput === pathInput) {
24511 updateReportTitleFromPath();
24512 autoSetOutputDir(data.selected_path);
24513 fetchProjectHistory(data.selected_path);
24514 loadPreview();
24515 suggestCoverageFile(data.selected_path);
24516 }
24517
24518 updateReview();
24519 } else if (targetInput === pathInput) {
24520 loadPreview();
24521 }
24522 })
24523 .catch(function () {
24524 window.alert("Directory picker request failed.");
24525 if (previewPanel && targetInput === pathInput) {
24526 previewPanel.innerHTML = '<div class="preview-error">Directory picker request failed.</div>';
24527 }
24528 })
24529 .finally(function () {
24530 if (browseButton) browseButton.disabled = false;
24531 });
24532 }
24533
24534 if (themeToggle) {
24535 themeToggle.addEventListener("click", function () {
24536 var nextTheme = document.body.classList.contains("dark-theme") ? "light" : "dark";
24537 applyTheme(nextTheme);
24538 try { localStorage.setItem("oxide-sloc-theme", nextTheme); } catch (e) {}
24539 });
24540 }
24541
24542 stepButtons.forEach(function (button) {
24543 button.addEventListener("click", function () {
24544 var target = Number(button.getAttribute("data-step-target"));
24545 // Block jumping forward off step 1 while the preview / upload is running
24546 // or while a multi-repository selection is unacknowledged.
24547 if (step1ForwardBlocked() && currentStep === 1 && target > 1) return;
24548 setStep(target);
24549 });
24550 });
24551
24552 Array.prototype.slice.call(document.querySelectorAll(".jump-step")).forEach(function (button) {
24553 button.addEventListener("click", function () {
24554 var target = Number(button.getAttribute("data-step-target")) || 1;
24555 if (step1ForwardBlocked() && currentStep === 1 && target > 1) return;
24556 setStep(target);
24557 });
24558 });
24559
24560 // True when the project path is untouched from the bundled sample default.
24561 function isDefaultSamplePath() {
24562 return !GIT_MODE && pathInput && pathInput.value.trim() === "testing/fixtures/basic";
24563 }
24564
24565 var defaultPathOverlay = document.getElementById("default-path-overlay");
24566 function closeDefaultPathModal() {
24567 if (defaultPathOverlay) defaultPathOverlay.classList.remove("open");
24568 }
24569 function openDefaultPathModal() {
24570 if (defaultPathOverlay) defaultPathOverlay.classList.add("open");
24571 }
24572
24573 Array.prototype.slice.call(document.querySelectorAll(".next-step")).forEach(function (button) {
24574 // Skip buttons that aren't real wizard navigation (e.g. modal action buttons
24575 // that borrow the .next-step style class but carry no data-next target).
24576 if (!button.hasAttribute("data-next")) return;
24577 button.addEventListener("click", function () {
24578 // Guard step 1 → 2: block while the scope preview / upload is still running
24579 // or while a multi-repository selection is unacknowledged.
24580 if (button.getAttribute("data-next") === "2" && step1ForwardBlocked()) return;
24581 // Guard step 1 → 2: warn when the project path is still the sample default.
24582 if (button.getAttribute("data-next") === "2" && isDefaultSamplePath()) {
24583 openDefaultPathModal();
24584 return;
24585 }
24586 updateReview();
24587 setStep(Number(button.getAttribute("data-next")));
24588 });
24589 });
24590
24591 Array.prototype.slice.call(document.querySelectorAll(".prev-step")).forEach(function (button) {
24592 if (!button.hasAttribute("data-prev")) return;
24593 button.addEventListener("click", function () {
24594 setStep(Number(button.getAttribute("data-prev")));
24595 });
24596 });
24597
24598 // Default-sample-path confirmation modal wiring.
24599 var defaultPathProceed = document.getElementById("default-path-proceed");
24600 if (defaultPathProceed) {
24601 defaultPathProceed.addEventListener("click", function () {
24602 closeDefaultPathModal();
24603 updateReview();
24604 setStep(2);
24605 });
24606 }
24607 var defaultPathCancel = document.getElementById("default-path-cancel");
24608 if (defaultPathCancel) {
24609 defaultPathCancel.addEventListener("click", function () {
24610 closeDefaultPathModal();
24611 if (pathInput) { pathInput.focus(); pathInput.select(); }
24612 });
24613 }
24614 if (defaultPathOverlay) {
24615 defaultPathOverlay.addEventListener("click", function (e) {
24616 if (e.target === defaultPathOverlay) closeDefaultPathModal();
24617 });
24618 }
24619 document.addEventListener("keydown", function (e) {
24620 if (e.key === "Escape" && defaultPathOverlay && defaultPathOverlay.classList.contains("open")) {
24621 closeDefaultPathModal();
24622 }
24623 });
24624
24625 document.addEventListener("keydown", function (e) {
24626 var tag = (document.activeElement || {}).tagName || "";
24627 if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
24628 if (e.altKey || e.ctrlKey || e.metaKey) return;
24629 if (e.key === "ArrowRight" && currentStep < 4) {
24630 if (currentStep === 1 && step1ForwardBlocked()) return;
24631 if (currentStep === 1 && isDefaultSamplePath()) { openDefaultPathModal(); return; }
24632 updateReview(); setStep(currentStep + 1);
24633 }
24634 else if (e.key === "ArrowLeft" && currentStep > 1) { setStep(currentStep - 1); }
24635 });
24636
24637 if (useSamplePath) {
24638 useSamplePath.addEventListener("click", function () {
24639 pathInput.value = "testing/fixtures/basic";
24640 updateReportTitleFromPath();
24641 autoSetOutputDir("testing/fixtures/basic");
24642 loadPreview();
24643 suggestCoverageFile("testing/fixtures/basic");
24644 });
24645 }
24646
24647 if (useDefaultOutput) {
24648 useDefaultOutput.addEventListener("click", function () {
24649 delete outputDirInput.dataset.userEdited;
24650 autoSetOutputDir(pathInput ? pathInput.value : "");
24651 updateReview();
24652 });
24653 }
24654
24655 if (browsePath) browsePath.addEventListener("click", function () { pickDirectory(pathInput, "project"); });
24656 if (browseOutputDir) browseOutputDir.addEventListener("click", function () { pickDirectory(outputDirInput, "output"); });
24657
24658 // ── Drag-and-drop directory upload (server mode only) ─────────────────
24659 // Dropping a folder onto the path field bypasses Chrome's
24660 // "Upload X files to this site?" confirmation dialog.
24661 async function readDirRecursively(dirEntry, basePath) {
24662 var reader = dirEntry.createReader();
24663 var all = [];
24664 for (;;) {
24665 var batch = await new Promise(function(res) { reader.readEntries(res, function() { res([]); }); });
24666 if (!batch.length) break;
24667 for (var i = 0; i < batch.length; i++) all.push(batch[i]);
24668 }
24669 var SKIP = new Set(['node_modules','.git','.hg','vendor','dist','build','target','__pycache__','.svn','.idea','.vscode']);
24670 var out = [];
24671 for (var i = 0; i < all.length; i++) {
24672 var sub = all[i];
24673 if (sub.isFile) {
24674 var f = await new Promise(function(res) { sub.file(res); });
24675 out.push({ file: f, path: basePath + '/' + sub.name });
24676 } else if (sub.isDirectory && !SKIP.has(sub.name)) {
24677 var nested = await readDirRecursively(sub, basePath + '/' + sub.name);
24678 for (var j = 0; j < nested.length; j++) out.push(nested[j]);
24679 }
24680 }
24681 return out;
24682 }
24683
24684 function setupPathDropZone() {
24685 if (!SERVER_MODE || !pathInput) return;
24686 var CODE_EXTS = new Set([
24687 'rs','py','js','ts','jsx','tsx','c','cpp','cc','cxx','h','hpp','hh','hxx',
24688 'java','go','rb','php','cs','swift','kt','kts','sh','bash','zsh','ksh','fish',
24689 'html','htm','css','scss','sass','svelte','vue','sql','lua','r','dart','zig',
24690 'nim','ex','exs','erl','hrl','fs','fsx','fsi','fsproj','clj','cljs','cljc',
24691 'hs','lhs','pl','pm','t','groovy','scala','m','mm','jl','ps1','psm1','psd1',
24692 'asm','s','S','lisp','el','rkt','ml','mli','tf','hcl','proto','thrift','graphql','gql'
24693 ]);
24694 pathInput.addEventListener('dragover', function(e) {
24695 e.preventDefault();
24696 pathInput.classList.add('drag-over');
24697 });
24698 pathInput.addEventListener('dragleave', function() { pathInput.classList.remove('drag-over'); });
24699 pathInput.addEventListener('drop', function(e) {
24700 e.preventDefault();
24701 pathInput.classList.remove('drag-over');
24702 var items = e.dataTransfer.items;
24703 if (!items || !items.length) return;
24704 var dirEntry = null;
24705 for (var i = 0; i < items.length; i++) {
24706 var entry = items[i].webkitGetAsEntry && items[i].webkitGetAsEntry();
24707 if (entry && entry.isDirectory) { dirEntry = entry; break; }
24708 }
24709 if (!dirEntry) { showBannerToast('Drop a project folder (not individual files).', true); return; }
24710 var btn = browsePath;
24711 if (btn) btn.disabled = true;
24712 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Reading folder contents\u2026</div>';
24713
24714 readDirRecursively(dirEntry, dirEntry.name).then(async function(allEntries) {
24715 var total = allEntries.length;
24716 var codeEntries = allEntries.filter(function(e) {
24717 var n = e.file.name;
24718 if (n === 'Makefile' || n === 'Dockerfile' || n === 'Gemfile' || n === 'Rakefile' || n === 'Procfile' || n === 'Justfile') return true;
24719 var dot = n.lastIndexOf('.');
24720 return dot >= 0 && CODE_EXTS.has(n.slice(dot + 1).toLowerCase());
24721 });
24722 var kept = codeEntries.length;
24723 if (kept === 0) {
24724 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">No supported source files found (' + total.toLocaleString() + ' files scanned).</div>';
24725 if (btn) btn.disabled = false; return;
24726 }
24727
24728 function finish(tmpPath, sizes) {
24729 pathInput.value = tmpPath;
24730 scrollInputToEnd(pathInput);
24731 if (sizes) {
24732 window._lastUploadSizes = sizes;
24733 var sizeText = document.getElementById('project-size-text');
24734 var sizeBtn = document.getElementById('project-size-btn');
24735 if (sizeText) sizeText.textContent = 'Original: ' + fmtBytes(sizes.original_bytes) +
24736 ' \u00b7 Compressed: ' + fmtBytes(sizes.compressed_bytes);
24737 if (sizeBtn) sizeBtn.title = 'Original project size: ' + fmtBytes(sizes.original_bytes) +
24738 ' \u2014 Compressed archive size: ' + fmtBytes(sizes.compressed_bytes);
24739 }
24740 updateReportTitleFromPath();
24741 autoSetOutputDir(tmpPath);
24742 fetchProjectHistory(tmpPath);
24743 loadPreview();
24744 suggestCoverageFile(tmpPath);
24745 updateReview();
24746 if (btn) btn.disabled = false;
24747 }
24748
24749 if (typeof CompressionStream === 'undefined') {
24750 showBannerToast('Your browser lacks CompressionStream. Use the \u201cUpload\u201d button instead.', true);
24751 if (btn) btn.disabled = false; return;
24752 }
24753
24754 try {
24755 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: 0 / ' + kept.toLocaleString() + ' files\u2026</div>';
24756 var BLOCK = 512;
24757 var cs = new CompressionStream('gzip');
24758 var wtr = cs.writable.getWriter();
24759 var chunks = [];
24760 var rdr = cs.readable.getReader();
24761 var collecting = (async function() { while (true) { var r = await rdr.read(); if (r.done) break; chunks.push(r.value); } })();
24762
24763 function buildHdr(fp, sz) {
24764 var hdr = new Uint8Array(BLOCK);
24765 var enc = new TextEncoder();
24766 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]; }
24767 function wO(o, l, v) { var s = v.toString(8); while (s.length < l - 1) s = '0' + s; wS(o, l, s + '\0'); }
24768 var nm = fp, pfx = '';
24769 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); } }
24770 wS(0,100,nm); wO(100,8,0o000644); wO(108,8,0); wO(116,8,0); wO(124,12,sz); wO(136,12,0);
24771 for (var i = 148; i < 156; i++) hdr[i] = 32;
24772 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);
24773 var chk = 0; for (var i = 0; i < BLOCK; i++) chk += hdr[i];
24774 var cv = chk.toString(8); while (cv.length < 6) cv = '0' + cv; wS(148,8,cv+'\0 ');
24775 return hdr;
24776 }
24777
24778 for (var i = 0; i < codeEntries.length; i++) {
24779 var ce = codeEntries[i];
24780 var buf = await ce.file.arrayBuffer();
24781 var data = new Uint8Array(buf);
24782 await wtr.write(buildHdr(ce.path, data.length));
24783 if (data.length > 0) { var padded = Math.ceil(data.length / BLOCK) * BLOCK; var blk = new Uint8Array(padded); blk.set(data); await wtr.write(blk); }
24784 if ((i + 1) % 50 === 0 || i === codeEntries.length - 1)
24785 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Building archive: ' + (i+1).toLocaleString() + ' / ' + kept.toLocaleString() + ' files\u2026</div>';
24786 }
24787 await wtr.write(new Uint8Array(BLOCK * 2));
24788 await wtr.close();
24789 await collecting;
24790
24791 var blob = new Blob(chunks, { type: 'application/gzip' });
24792 var sizeMB = (blob.size / 1048576).toFixed(1);
24793 if (previewPanel) previewPanel.innerHTML = '<div class="preview-error">Uploading compressed archive (' + sizeMB + ' MB, ' + kept.toLocaleString() + ' files)\u2026</div>';
24794 var resp = await fetch('/api/upload-tarball', { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: blob });
24795 var d = await resp.json();
24796 if (d && d.tmp_path) {
24797 finish(d.tmp_path, { compressed_bytes: d.compressed_bytes || 0, original_bytes: d.original_bytes || 0 });
24798 } else { showBannerToast((d && d.error) ? d.error : 'Upload failed', true); if (btn) btn.disabled = false; }
24799 } catch (err) {
24800 showBannerToast('Upload failed: ' + String(err), true);
24801 if (btn) btn.disabled = false;
24802 }
24803 }).catch(function(err) {
24804 showBannerToast('Could not read folder: ' + String(err), true);
24805 if (btn) btn.disabled = false;
24806 });
24807 });
24808 }
24809 setupPathDropZone();
24810 if (browseCoverage) {
24811 browseCoverage.addEventListener("click", function () {
24812 pickDirectory(coverageInput || pathInput, "coverage");
24813 });
24814 }
24815
24816 function setCovStatus(state, opts) {
24817 if (!covScanStatus) return;
24818 opts = opts || {};
24819 covScanStatus.className = "cov-scan-status cov-scan-" + state;
24820 if (state === "idle") { covScanStatus.innerHTML = ""; return; }
24821 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>';
24822 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>';
24823 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>';
24824 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>';
24825 var icons = { scanning: ICON_SCAN, found: ICON_OK, hint: ICON_WARN, none: ICON_NONE };
24826 var html = '<div class="cov-scan-inner"><div class="cov-scan-icon">' + (icons[state] || "") + '</div><div class="cov-scan-body">';
24827 if (state === "scanning") {
24828 html += '<div class="cov-scan-title">Scanning project for coverage files\u2026</div>';
24829 } else if (state === "found") {
24830 var tb = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
24831 html += '<div class="cov-scan-title">Coverage file auto-detected! ' + tb + '</div>';
24832 html += '<div class="cov-scan-sub">' + escapeHtml(opts.found) + '</div>';
24833 html += '<div class="cov-scan-actions"><button type="button" class="cov-scan-use cov-scan-remove">Remove</button></div>';
24834 } else if (state === "hint") {
24835 var tb2 = opts.tool ? '<span class="cov-scan-tool">' + escapeHtml(opts.tool) + '</span>' : '';
24836 html += '<div class="cov-scan-title">' + tb2 + ' project — no coverage report found yet</div>';
24837 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>';
24838 } else if (state === "none") {
24839 html += '<div class="cov-scan-title">No coverage files detected in this project</div>';
24840 html += '<div class="cov-scan-sub">Supported: LCOV\u00a0.info · Cobertura\u00a0XML · JaCoCo\u00a0XML · coverage.py\u00a0JSON · Istanbul\u00a0JSON</div>';
24841 }
24842 html += '</div></div>';
24843 covScanStatus.innerHTML = html;
24844 if (state === "found") {
24845 var useBtn = covScanStatus.querySelector(".cov-scan-use");
24846 if (useBtn) useBtn.addEventListener("click", function () {
24847 if (coverageInput) coverageInput.value = "";
24848 covAutoFilled = false;
24849 setCovStatus("idle");
24850 });
24851 }
24852 }
24853
24854 function suggestCoverageFile(projectPath) {
24855 if (!coverageInput || !covScanStatus) return;
24856 if (coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
24857 if (covAutoFilled) { coverageInput.value = ""; covAutoFilled = false; }
24858 clearTimeout(coverageSuggestTimer);
24859 if (!projectPath || !projectPath.trim()) { setCovStatus("idle"); return; }
24860 setCovStatus("scanning");
24861 coverageSuggestTimer = setTimeout(function () {
24862 fetch("/api/suggest-coverage?path=" + encodeURIComponent(projectPath))
24863 .then(function (r) { return r.json(); })
24864 .then(function (d) {
24865 if (coverageInput && coverageInput.value.trim() && !covAutoFilled) { setCovStatus("idle"); return; }
24866 if (!d) { setCovStatus("none"); return; }
24867 if (d.found) {
24868 if (coverageInput) { coverageInput.value = d.found; covAutoFilled = true; }
24869 setCovStatus("found", { found: d.found, tool: d.tool });
24870 } else if (d.tool && d.hint) {
24871 setCovStatus("hint", { tool: d.tool, hint: d.hint });
24872 } else {
24873 setCovStatus("none");
24874 }
24875 })
24876 .catch(function () { setCovStatus("idle"); });
24877 }, 600);
24878 }
24879
24880 if (refreshPreviewInline) refreshPreviewInline.addEventListener("click", loadPreview);
24881
24882 if (coverageInput) coverageInput.addEventListener("input", function () {
24883 covAutoFilled = false;
24884 if (!this.value.trim()) setCovStatus("idle");
24885 });
24886
24887 // ── Language pill overflow: collapse to "+N more" chip ─────────────
24888 function collapseLanguagePills() {
24889 var rows = Array.prototype.slice.call(document.querySelectorAll('.language-pill-row.iconified'));
24890 rows.forEach(function(row) {
24891 // Remove any previous overflow chip
24892 var prev = row.querySelector('.lang-overflow-chip');
24893 if (prev) prev.remove();
24894 var pills = Array.prototype.slice.call(row.querySelectorAll('.detected-language-chip'));
24895 pills.forEach(function(p) { p.style.display = ''; });
24896 if (!pills.length) return;
24897
24898 // Measure after restoring all pills
24899 var containerRight = row.getBoundingClientRect().right;
24900 var hidden = [];
24901 for (var i = pills.length - 1; i >= 1; i--) {
24902 var rect = pills[i].getBoundingClientRect();
24903 if (rect.right > containerRight + 2) {
24904 hidden.unshift(pills[i]);
24905 pills[i].style.display = 'none';
24906 } else {
24907 break;
24908 }
24909 }
24910
24911 if (hidden.length) {
24912 var chip = document.createElement('button');
24913 chip.type = 'button';
24914 chip.className = 'language-pill lang-overflow-chip';
24915 var names = hidden.map(function(p) { return p.querySelector('span') ? p.querySelector('span').textContent.trim() : p.textContent.trim(); });
24916 chip.innerHTML = '+' + hidden.length + '<div class="lang-overflow-tip">' + names.join('\n') + '</div>';
24917 row.appendChild(chip);
24918 }
24919 });
24920 }
24921
24922 // Run after preview loads (preview panel populates language pills)
24923 var _origLoadPreviewCb = window.__previewLoaded;
24924 document.addEventListener('previewLoaded', collapseLanguagePills);
24925 window.addEventListener('resize', function() { clearTimeout(window._collapseTimer); window._collapseTimer = setTimeout(collapseLanguagePills, 120); });
24926 setTimeout(collapseLanguagePills, 400);
24927
24928 // ── Project history & output dir auto-set ──────────────────────────
24929 var wsOutputRoot = document.getElementById("ws-output-root");
24930 var wsScanCount = document.getElementById("ws-scan-count");
24931 var wsLastScan = document.getElementById("ws-last-scan");
24932 var historyBadge = document.getElementById("path-history-badge");
24933 var historyTimer = null;
24934
24935 var wsOutputLink = document.getElementById("ws-output-link");
24936 function syncStripOutputRoot() {
24937 var val = outputDirInput ? outputDirInput.value : "";
24938 var display = val || "project/sloc";
24939 if (wsOutputRoot) wsOutputRoot.textContent = display;
24940 if (wsOutputLink) wsOutputLink.dataset.folder = val;
24941 }
24942
24943 function scrollInputToEnd(input) {
24944 if (!input) return;
24945 // Defer so the DOM has the new value before we measure scroll width.
24946 requestAnimationFrame(function () {
24947 input.scrollLeft = input.scrollWidth;
24948 input.selectionStart = input.selectionEnd = input.value.length;
24949 });
24950 }
24951
24952 function autoSetOutputDir(projectPath) {
24953 if (!outputDirInput || outputDirInput.dataset.userEdited) return;
24954 if (GIT_MODE && GIT_OUTPUT_DIR) {
24955 outputDirInput.value = GIT_OUTPUT_DIR;
24956 scrollInputToEnd(outputDirInput);
24957 syncStripOutputRoot();
24958 updateReview();
24959 return;
24960 }
24961 if (!projectPath || !projectPath.trim()) return;
24962 var cleaned = projectPath.trim().replace(/[\\\/]+$/, "");
24963 outputDirInput.value = cleaned + "/sloc";
24964 scrollInputToEnd(outputDirInput);
24965 syncStripOutputRoot();
24966 updateReview();
24967 }
24968
24969 var wsBranch = document.getElementById("ws-branch");
24970
24971 function fetchProjectHistory(projectPath) {
24972 if (!projectPath || !projectPath.trim()) {
24973 if (wsScanCount) wsScanCount.textContent = "\u2014";
24974 if (wsLastScan) wsLastScan.textContent = "\u2014";
24975 if (wsBranch) wsBranch.textContent = "\u2014";
24976 if (historyBadge) historyBadge.style.display = "none";
24977 return;
24978 }
24979 fetch("/api/project-history?path=" + encodeURIComponent(projectPath.trim()))
24980 .then(function (r) { return r.ok ? r.json() : null; })
24981 .then(function (data) {
24982 if (!data) return;
24983 var countStr = data.scan_count > 0
24984 ? data.scan_count + " scan" + (data.scan_count === 1 ? "" : "s")
24985 : "never";
24986 var tsStr = data.last_scan_timestamp
24987 ? data.last_scan_timestamp.replace(" UTC","")
24988 : "\u2014";
24989 if (wsScanCount) wsScanCount.textContent = countStr;
24990 if (wsLastScan) wsLastScan.textContent = tsStr;
24991 if (wsBranch) wsBranch.textContent = data.last_git_branch || "\u2014";
24992 if (data.scan_count > 0) {
24993 if (historyBadge) {
24994 var branch = data.last_git_branch ? " on " + data.last_git_branch : "";
24995 historyBadge.textContent = data.scan_count + " previous scan" +
24996 (data.scan_count === 1 ? "" : "s") + " found" + branch + ". " +
24997 "Last: " + (data.last_scan_timestamp || "\u2014") +
24998 " \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.";
24999 historyBadge.className = "path-history-badge found";
25000 historyBadge.style.display = "";
25001 }
25002 } else {
25003 if (historyBadge) historyBadge.style.display = "none";
25004 }
25005 })
25006 .catch(function () {});
25007 }
25008
25009 function onPathChange() {
25010 var val = pathInput ? pathInput.value : "";
25011 // Discard stale upload sizes when the user edits the path manually.
25012 window._lastUploadSizes = null;
25013 updateReportTitleFromPath();
25014 autoSetOutputDir(val);
25015 updateSidebarSummary();
25016 clearTimeout(historyTimer);
25017 historyTimer = setTimeout(function () { fetchProjectHistory(val); }, 400);
25018 if (previewTimer) clearTimeout(previewTimer);
25019 previewTimer = setTimeout(loadPreview, 280);
25020 suggestCoverageFile(val);
25021 }
25022
25023 if (pathInput) {
25024 pathInput.addEventListener("input", onPathChange);
25025 }
25026
25027 if (outputDirInput) {
25028 outputDirInput.addEventListener("input", function () {
25029 outputDirInput.dataset.userEdited = "1";
25030 syncStripOutputRoot();
25031 updateReview();
25032 });
25033 }
25034
25035 [includeGlobsInput, excludeGlobsInput].forEach(function (node) {
25036 if (!node) return;
25037 node.addEventListener("input", function () {
25038 updateReview();
25039 if (previewTimer) clearTimeout(previewTimer);
25040 previewTimer = setTimeout(loadPreview, 280);
25041 });
25042 });
25043
25044 ["generated_file_detection", "minified_file_detection", "vendor_directory_detection", "include_lockfiles", "binary_file_behavior"].forEach(function (id) {
25045 var node = document.getElementById(id);
25046 if (node) node.addEventListener("change", updateReview);
25047 });
25048
25049 if (reportTitleInput) {
25050 reportTitleInput.addEventListener("input", function () {
25051 reportTitleTouched = reportTitleInput.value.trim().length > 0;
25052 updateReportTitleFromPath();
25053 updateReview();
25054 });
25055 }
25056
25057 if (mixedLinePolicy) mixedLinePolicy.addEventListener("change", function () { updateMixedPolicyUI(); updateReview(); });
25058 if (pythonDocstrings) pythonDocstrings.addEventListener("change", function () { updatePythonDocstringUI(); updateReview(); });
25059 if (scanPreset) scanPreset.addEventListener("change", function () { applyScanPreset(); updatePresetDescriptions(); updateReview(); updateSidebarSummary(); });
25060 if (artifactPreset) artifactPreset.addEventListener("change", function () { updatePresetDescriptions(); applyArtifactPreset(); updateReview(); updateSidebarSummary(); });
25061
25062 if (coverageInput) {
25063 coverageInput.addEventListener("input", function () {
25064 if (coverageInput.value.trim()) setCovStatus("idle");
25065 });
25066 }
25067
25068 if (form && loading && submitButton) {
25069 form.addEventListener("submit", function (e) {
25070 e.preventDefault();
25071 submitButton.disabled = true;
25072 submitButton.textContent = "Scanning...";
25073 startAsyncAnalysis(new FormData(form));
25074 });
25075 }
25076
25077 function openPath(folder) {
25078 if (!folder) return;
25079 fetch('/open-path?path=' + encodeURIComponent(folder))
25080 .then(function (r) { return r.json(); })
25081 .then(function (d) {
25082 if (d && d.server_mode_disabled)
25083 showBannerToast(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
25084 })
25085 .catch(function () {});
25086 }
25087
25088 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
25089 btn.addEventListener('click', function () {
25090 openPath(btn.getAttribute('data-folder') || btn.dataset.folder || '');
25091 });
25092 });
25093
25094 // Re-bind any dynamically added open-folder-buttons (e.g. ws-output-link after path change)
25095 if (wsOutputLink) {
25096 wsOutputLink.addEventListener('click', function () {
25097 openPath(wsOutputLink.dataset.folder || '');
25098 });
25099 }
25100
25101 loadSavedTheme();
25102 updateMixedPolicyUI();
25103 updatePythonDocstringUI();
25104 applyScanPreset();
25105 updatePresetDescriptions();
25106 applyArtifactPreset();
25107 updateReview();
25108 updateScrollProgress(); // initialise bar to 0% (step 1)
25109 window.addEventListener("scroll", updateScrollProgress, { passive: true });
25110 onPathChange(); // seed output dir, history badge, and preview from initial path
25111 updateStepNav(1);
25112
25113 // Restore step from URL hash on initial load (e.g., back-forward cache)
25114 (function() {
25115 var hashMatch = location.hash.match(/^#step([1-4])$/);
25116 if (hashMatch) { var s = Number(hashMatch[1]); if (s > 1) setStep(s, false); }
25117 })();
25118
25119 (function randomizeWatermarks() {
25120 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
25121 if (!wms.length) return;
25122 var placed = [];
25123 function tooClose(top, left) {
25124 for (var i = 0; i < placed.length; i++) {
25125 var dt = Math.abs(placed[i][0] - top);
25126 var dl = Math.abs(placed[i][1] - left);
25127 if (dt < 16 && dl < 12) return true;
25128 }
25129 return false;
25130 }
25131 function pick(leftBand) {
25132 for (var attempt = 0; attempt < 50; attempt++) {
25133 var top = Math.random() * 88 + 2;
25134 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
25135 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
25136 }
25137 var top = Math.random() * 88 + 2;
25138 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
25139 placed.push([top, left]);
25140 return [top, left];
25141 }
25142 var half = Math.floor(wms.length / 2);
25143 wms.forEach(function (img, i) {
25144 var pos = pick(i < half);
25145 var size = Math.floor(Math.random() * 80 + 110);
25146 var rot = (Math.random() * 360).toFixed(1);
25147 var op = (Math.random() * 0.08 + 0.13).toFixed(2);
25148 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;
25149 });
25150 })();
25151
25152 (function spawnCodeParticles() {
25153 var container = document.getElementById('code-particles');
25154 if (!container) return;
25155 var snippets = ['NUM sloc','fn analyze()','code_lines','0 mixed','blanks: NUM','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','NUM physical','files: NUM','NUM comments','cargo build','Ok(run)','Vec<GEN>','match lang','fn main()','.rs .go .py','sloc_core','render_html','NUM code','HashMap<GEN, GN2>','Option<GEN>','&str','author AUT','last_change','ratio 0.CPX','complexity CPX','tokens: NUM','?; // try','let owner','commits: NUM','#[tokio::test]','assert_eq!','PCT% cover','blame AUT','churn PCT%','HEAD~CPX','dedup PCT%','cyclomatic CPX','impl Iterator','.await','+NUM -NM2','ownership','Span<GEN>','pub struct','trait Lang','unsafe','mixed: NUM','anyhow::Result','fn detect()','PathBuf','+NUM loc','coverage PCT%','physical: NUM','ULOC NUM','dup PCT%','let mut run']; var __GT=['Author','String','Token','FileRecord','Lang','u32','usize','PathBuf','Commit','Line','Span','Metric','Record','u64','char','Utf8','Ratio','Blame']; var __AN=['nima','core','web','cli','git','lang','ci','bot','sloc'];
25156 for (var i = 0; i < 44; i++) {
25157 (function(idx) {
25158 var el = document.createElement('span');
25159 el.className = 'code-particle';
25160 el.textContent = snippets[idx % snippets.length].split('NUM').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('NM2').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('GEN').join(__GT[Math.floor(Math.random()*__GT.length)]).split('GN2').join(__GT[Math.floor(Math.random()*__GT.length)]).split('AUT').join(__AN[Math.floor(Math.random()*__AN.length)]).split('CPX').join(''+Math.floor(Math.random()*40+1)).split('PCT').join(''+Math.floor(Math.random()*44+1));
25161 var left = Math.random() * 94 + 2;
25162 var top = Math.random() * 88 + 6;
25163 var dur = (Math.random() * 10 + 9).toFixed(1);
25164 var delay = (Math.random() * 18).toFixed(1);
25165 var rot = (Math.random() * 26 - 13).toFixed(1);
25166 var op = (Math.random() * 0.108 + 0.072).toFixed(3);
25167 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';
25168 container.appendChild(el);
25169 })(i);
25170 }
25171 })();
25172 })();
25173 </script>
25174 <script nonce="{{ csp_nonce }}">
25175 (function () {
25176 var raw = {{ prefill_json|safe }};
25177 if (!raw || typeof raw !== 'object' || !raw.path) return;
25178 function setVal(id, val) { var el = document.getElementById(id); if (el) { el.value = val; if (id === 'output_dir') scrollInputToEnd(el); } }
25179 function setChecked(id, v) { var el = document.getElementById(id); if (el) el.checked = v; }
25180 function setSelect(id, val) { var el = document.getElementById(id); if (el) el.value = val; }
25181 setVal('path', raw.path || '');
25182 setVal('include_globs', raw.include_globs || '');
25183 setVal('exclude_globs', raw.exclude_globs || '');
25184 setVal('output_dir', raw.output_dir || '');
25185 setVal('report_title', raw.report_title || '');
25186 if (raw.submodule_breakdown) setChecked('submodule_breakdown', true);
25187 setSelect('mixed_line_policy', raw.mixed_line_policy || 'code_only');
25188 setChecked('python_docstrings_as_comments', !!raw.python_docstrings_as_comments);
25189 setSelect('generated_file_detection', raw.generated_file_detection ? 'enabled' : 'disabled');
25190 setSelect('minified_file_detection', raw.minified_file_detection ? 'enabled' : 'disabled');
25191 setSelect('vendor_directory_detection', raw.vendor_directory_detection ? 'enabled' : 'disabled');
25192 if (raw.include_lockfiles) setSelect('include_lockfiles', 'enabled');
25193 setSelect('binary_file_behavior', raw.binary_file_behavior || 'skip');
25194 setChecked('generate_html', raw.generate_html !== false);
25195 setChecked('generate_pdf', !!raw.generate_pdf);
25196 if (raw.continuation_line_policy) setSelect('continuation_line_policy', raw.continuation_line_policy);
25197 if (raw.blank_in_block_comment_policy) setSelect('blank_in_block_comment_policy', raw.blank_in_block_comment_policy);
25198 setSelect('count_compiler_directives', raw.count_compiler_directives === false ? 'disabled' : 'enabled');
25199 setSelect('style_analysis_enabled', raw.style_analysis_enabled === false ? 'disabled' : 'enabled');
25200 if (raw.style_col_threshold) setSelect('style_col_threshold', String(raw.style_col_threshold));
25201 if (raw.style_score_threshold) setSelect('style_score_threshold', String(raw.style_score_threshold));
25202 if (raw.style_lang_scope) setSelect('style_lang_scope', raw.style_lang_scope);
25203 if (raw.coverage_file) setVal('coverage_file', raw.coverage_file);
25204 if (raw.cocomo_mode) setSelect('cocomo_mode', raw.cocomo_mode);
25205 if (raw.complexity_alert) setVal('complexity_alert', String(raw.complexity_alert));
25206 if (raw.activity_window !== undefined && raw.activity_window !== null) setVal('activity_window', String(raw.activity_window));
25207 setSelect('exclude_duplicates', raw.exclude_duplicates ? 'enabled' : 'disabled');
25208 setSelect('attribution', raw.attribution ? 'enabled' : 'disabled');
25209 // Trigger dynamic UI updates after pre-fill.
25210 setTimeout(function () {
25211 var pathEl = document.getElementById('path');
25212 if (pathEl) pathEl.dispatchEvent(new Event('input', { bubbles: true }));
25213 var policyEl = document.getElementById('mixed_line_policy');
25214 if (policyEl) policyEl.dispatchEvent(new Event('change', { bubbles: true }));
25215 }, 80);
25216 })();
25217 </script>
25218 <script nonce="{{ csp_nonce }}">
25219 (function(){
25220 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'}];
25221 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);});}
25222 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
25223 function init(){
25224 var btn=document.getElementById('settings-btn');if(!btn)return;
25225 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
25226 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 class="sx-dec3b281" ><div class="settings-modal-label sx-c500155b" >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>';
25227 document.body.appendChild(m);
25228 var g=document.getElementById('scheme-grid');
25229 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);});
25230 var cl=document.getElementById('settings-close');
25231 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);});})();
25232 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');});
25233 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
25234 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
25235 }
25236 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
25237 }());
25238 </script>
25239 <div class="wb-ftip" id="wb-ftip" role="tooltip" aria-hidden="true">
25240 <div class="wb-ftip-arrow"></div>
25241 <span id="wb-ftip-text"></span>
25242 </div>
25243 <script nonce="{{ csp_nonce }}">(function(){
25244 var tip=document.getElementById('wb-ftip');
25245 var txt=document.getElementById('wb-ftip-text');
25246 var arr=tip?tip.querySelector('.wb-ftip-arrow'):null;
25247 if(!tip||!txt)return;
25248 function pos(el){
25249 var r=el.getBoundingClientRect();
25250 tip.style.display='block';
25251 var tw=tip.offsetWidth;
25252 var lx=r.left+r.width/2-tw/2;
25253 if(lx<8)lx=8;
25254 if(lx+tw>window.innerWidth-8)lx=window.innerWidth-tw-8;
25255 tip.style.left=lx+'px';
25256 tip.style.top=(r.bottom+8)+'px';
25257 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';}
25258 }
25259 document.querySelectorAll('[data-wb-tip]').forEach(function(el){
25260 el.addEventListener('mouseenter',function(){txt.textContent=el.getAttribute('data-wb-tip');pos(el);});
25261 el.addEventListener('mouseleave',function(){tip.style.display='none';});
25262 });
25263 window.addEventListener('blur',function(){tip.style.display='none';});
25264 document.addEventListener('visibilitychange',function(){if(document.hidden)tip.style.display='none';});
25265 })();
25266 (function(){
25267 function fixArtifactHintSpacing(){
25268 var grid=document.querySelector('.artifact-grid');
25269 if(grid){grid.style.setProperty('margin-bottom','48px','important');}
25270 }
25271 if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',fixArtifactHintSpacing);}else{fixArtifactHintSpacing();}
25272 }());
25273 (function(){
25274 var dot=document.getElementById('status-dot');
25275 var pingEl=document.getElementById('server-ping-ms');
25276 var tipEl=document.getElementById('server-tip-ping');
25277 var fm=document.getElementById('footer-mode');
25278 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)';}}
25279 function doPing(){
25280 var t0=performance.now();
25281 fetch('/healthz',{cache:'no-store'})
25282 .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);})
25283 .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)';}});
25284 }
25285 doPing();
25286 setInterval(doPing,5000);
25287 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');}
25288 })();
25289 </script>
25290 <span class="sx-dab0f2f8" id="page-bottom" aria-hidden="true" ></span>
25291 <footer class="site-footer">
25292 local code analysis - metrics, history and reports
25293 · <em class="footer-mode sx-e01b0d98" id="footer-mode" >oxide-sloc v{{ version }} — Mode: {% if server_mode %}Network Server{% else %}Local{% endif %}</em>
25294 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25295 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25296 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25297 · <a href="/report-bug" rel="noopener">Report a Bug</a> · <a href="/api-docs" rel="noopener">REST API</a>
25298 </footer>
25299</body>
25300</html>
25301"##,
25302 ext = "html"
25303)]
25304struct IndexTemplate {
25305 version: &'static str,
25306 prefill_json: String,
25307 csp_nonce: String,
25308 git_repo: String,
25309 git_ref: String,
25310 git_label_json: String,
25311 git_output_dir_json: String,
25312 server_mode: bool,
25313}
25314
25315#[derive(Template)]
25318#[template(
25319 source = r##"
25320<!doctype html>
25321<html lang="en">
25322<head>
25323 <meta charset="utf-8">
25324 <meta name="viewport" content="width=device-width, initial-scale=1">
25325 <title>OxideSLOC — local code analysis - metrics, history and reports</title>
25326 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
25327 <link rel="stylesheet" href="/static/app.css">
25328 <script src="/static/app.js"></script>
25329 <script type="application/ld+json">
25330 {
25331 "@context": "https://schema.org",
25332 "@type": "SoftwareApplication",
25333 "name": "oxide-sloc",
25334 "applicationCategory": "DeveloperApplication",
25335 "operatingSystem": "Windows, Linux",
25336 "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.",
25337 "softwareVersion": "{{ version }}",
25338 "author": { "@type": "Person", "name": "Nima Shafie", "url": "https://github.com/NimaShafie" },
25339 "license": "https://www.gnu.org/licenses/agpl-3.0.html",
25340 "url": "https://github.com/oxide-sloc/oxide-sloc",
25341 "downloadUrl": "https://github.com/oxide-sloc/oxide-sloc/releases",
25342 "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",
25343 "programmingLanguage": "Rust",
25344 "keywords": "sloc, code analysis, source lines of code, metrics, MCP, AI agent"
25345 }
25346 </script>
25347 <style nonce="{{ csp_nonce }}">
25348 :root {
25349 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
25350 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
25351 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
25352 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
25353 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
25354 }
25355 body.dark-theme {
25356 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
25357 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
25358 }
25359 *{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;}
25360 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25361 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
25362 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
25363 .code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
25364 @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));}}
25365 .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);}
25366 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
25367 .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));}
25368 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
25369 .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;}
25370 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
25371 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
25372 @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; } }
25373 .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;}
25374 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
25375 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
25376 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
25377 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
25378 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
25379 .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;}
25380 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
25381 .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);}
25382 .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;}
25383 .settings-close:hover{color:var(--text);background:var(--surface-2);}
25384 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
25385 .settings-modal-body{padding:14px 16px 16px;}
25386 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
25387 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
25388 .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;}
25389 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
25390 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
25391 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
25392 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
25393 .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;}
25394 .tz-select:focus{border-color:var(--oxide);}
25395 .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;}
25396 .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;}
25397 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 12px;position:relative;z-index:1;}
25398 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
25399 .hero{text-align:center;margin:0 auto 18px;}
25400 .hero-logo-wrap{display:inline-block;cursor:default;}
25401 .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;}
25402 .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;}
25403 .hero-title-wrap{position:relative;display:inline-flex;flex-direction:column;align-items:center;}
25404 .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;}
25405 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%);}
25406 .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;
25407 background:linear-gradient(90deg,#b85d33 0%,#d37a4c 25%,#6f9bff 50%,#b85d33 75%,#d37a4c 100%);
25408 background-size:200% auto;-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;
25409 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;}
25410 @keyframes titleReveal{to{clip-path:inset(0 0% 0 0);}}
25411 @keyframes titleShimmer{0%{background-position:0% center;}100%{background-position:200% center;}}
25412 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;}
25413 .hero-subtitle{font-size:15px;color:var(--muted);line-height:1.55;max-width:600px;margin:0 auto;min-height:3.2em;opacity:0;}
25414 .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;}
25415 @keyframes cursorBlink{0%,100%{opacity:1;}50%{opacity:0;}}
25416 .card-sections{display:flex;flex-direction:column;gap:25px;margin:0 0 16px;}
25417 .card-section-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;padding-left:2px;}
25418 .card-section-grid-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;}
25419 .card-section-grid-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;}
25420 .card-section-grid-4{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;}
25421 @media(max-width:1150px){.card-section-grid-4{grid-template-columns:1fr 1fr;}}
25422 @media(max-width:900px){.card-section-grid-2,.card-section-grid-3,.card-section-grid-4{grid-template-columns:1fr 1fr;}}
25423 @media(max-width:480px){.card-section-grid-2,.card-section-grid-3,.card-section-grid-4{grid-template-columns:1fr;}}
25424 .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;}
25425 .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;}
25426 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
25427 @media(prefers-reduced-motion:reduce){.action-card,.lan-card{animation:none;}}
25428 .action-card:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
25429 .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);}
25430 .action-card:hover .action-card-icon{transform:rotate(-8deg) scale(1.12);}
25431 .action-card-icon svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;}
25432 .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);}
25433 .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);}
25434 .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);}
25435 .action-card-title{font-size:15px;font-weight:850;letter-spacing:-0.02em;margin:0 0 4px;}
25436 .action-card-desc{font-size:12px;color:var(--muted);line-height:1.55;margin:0 0 10px;flex:1;}
25437 .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;}
25438 body.dark-theme .action-card-cta{color:var(--oxide);}
25439 .action-card.view .action-card-cta{color:var(--accent-2);}
25440 body.dark-theme .action-card.view .action-card-cta{color:var(--accent);}
25441 .action-card.compare .action-card-cta{color:#7c3aed;}
25442 body.dark-theme .action-card.compare .action-card-cta{color:#a78bfa;}
25443 .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);}
25444 .action-card.git-tools .action-card-cta{color:#15803d;}
25445 body.dark-theme .action-card.git-tools .action-card-cta{color:#4ade80;}
25446 .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);}
25447 .action-card.trend .action-card-cta{color:#0e7490;}
25448 body.dark-theme .action-card.trend .action-card-cta{color:#22d3ee;}
25449 .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);}
25450 .action-card.automation .action-card-cta{color:#b45309;}
25451 body.dark-theme .action-card.automation .action-card-cta{color:#fbbf24;}
25452 .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);}
25453 .action-card.test-metrics .action-card-cta{color:#be185d;}
25454 body.dark-theme .action-card.test-metrics .action-card-cta{color:#f472b6;}
25455 .action-card.ownership .action-card-icon{background:linear-gradient(135deg,#6366f1,#4338ca);color:#fff;box-shadow:0 8px 22px rgba(99,102,241,0.28);}
25456 .action-card.ownership .action-card-cta{color:#4338ca;}
25457 body.dark-theme .action-card.ownership .action-card-cta{color:#a5b4fc;}
25458 .action-card:hover .action-card-cta{gap:12px;}
25459 .action-card.card-split{flex-direction:row;align-items:stretch;}
25460 .action-card-left{flex:1;display:flex;flex-direction:column;align-items:flex-start;}
25461 .action-card-sep{width:1px;background:var(--line);margin:0 12px;opacity:0.22;align-self:stretch;flex-shrink:0;}
25462 .action-card-right{width:170px;display:flex;flex-direction:column;justify-content:center;gap:10px;flex-shrink:0;}
25463 .ac-right-row{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:var(--muted);}
25464 .ac-right-row svg{width:14px;height:14px;stroke:var(--oxide);stroke-width:2;fill:none;flex-shrink:0;}
25465 .ac-right-stat{font-size:11px;color:var(--oxide);font-weight:700;margin-top:4px;min-height:14px;}
25466 .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;}
25467 .ac-badge.active{opacity:1;}
25468 .ac-badge.github{border-color:#555;color:#555;}
25469 .ac-badge.gitlab{border-color:#e24329;color:#e24329;}
25470 .ac-badge.bitbucket{border-color:#2684ff;color:#2684ff;}
25471 .ac-badge.confluence{border-color:#0052cc;color:#0052cc;}
25472 .ac-badges-grid{display:flex;flex-wrap:wrap;gap:5px;}
25473 body.dark-theme .ac-right-row{color:var(--muted);}
25474 body.dark-theme .ac-badge.github{border-color:#aaa;color:#aaa;}
25475 @media(max-width:600px){.action-card-sep,.action-card-right{display:none;}}
25476 .divider{height:1px;background:var(--line);margin:32px 0;}
25477 .info-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:9px;margin-bottom:23px;}
25478 @media(max-width:960px){.info-strip{grid-template-columns:repeat(3,1fr);}}
25479 @media(max-width:600px){.info-strip{grid-template-columns:repeat(2,1fr);}}
25480 .info-chip{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:9px 12px;text-align:center;position:relative;cursor:default;
25481 transition:transform 0.22s cubic-bezier(.34,1.56,.64,1),box-shadow 0.18s ease,border-color 0.18s ease;}
25482 .info-chip:hover{transform:translateY(-5px) scale(1.04);box-shadow:var(--shadow-strong);border-color:var(--oxide-2);}
25483 .info-chip-val{font-size:15px;font-weight:900;color:var(--oxide);}
25484 body.dark-theme .info-chip-val{color:var(--oxide);}
25485 .info-chip-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:2px;}
25486 .info-chip-tip{display:none;position:absolute;bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);z-index:50;
25487 background:var(--text);color:var(--bg);border-radius:9px;padding:8px 13px;font-size:12px;font-weight:600;line-height:1.4;
25488 white-space:nowrap;box-shadow:0 8px 24px rgba(0,0,0,0.22);pointer-events:none;}
25489 .info-chip-tip::after{content:"";position:absolute;top:100%;left:50%;transform:translateX(-50%);
25490 border:6px solid transparent;border-top-color:var(--text);}
25491 .info-chip:hover .info-chip-tip{display:block;}
25492 .chip-slide{transition:filter 0.70s ease,opacity 0.70s ease;}
25493 .chip-slide.fading{filter:blur(5px);opacity:0;}
25494 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
25495 .site-footer a{color:var(--muted);}
25496 .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;}
25497 .lan-card.server{border-color:#3b82f6;background:linear-gradient(135deg,rgba(59,130,246,0.06),var(--surface));}
25498 body.dark-theme .lan-card.server{background:linear-gradient(135deg,rgba(59,130,246,0.10),var(--surface));}
25499 .lan-card-header{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:800;margin-bottom:16px;letter-spacing:-0.01em;}
25500 .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;}
25501 .lan-badge.local{background:var(--oxide-2);}
25502 .lan-url-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;}
25503 .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);}
25504 body.dark-theme .lan-url{color:#93c5fd;background:rgba(59,130,246,0.14);border-color:rgba(59,130,246,0.28);}
25505 .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;}
25506 .lan-copy-btn:hover{background:rgba(59,130,246,0.10);border-color:#3b82f6;color:#2563eb;}
25507 .lan-hint{font-size:13px;color:var(--muted);line-height:1.5;margin-bottom:12px;}
25508 .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;}
25509 body.dark-theme .lan-auth-row{background:rgba(255,255,255,0.04);}
25510 .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;}
25511 .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);}
25512 body.dark-theme .lan-local-hint{border-color:rgba(255,255,255,0.08);background:rgba(255,255,255,0.03);}
25513 body.dark-theme .lan-local-hint code{background:rgba(255,255,255,0.06);}
25514 .lan-local-hint strong{color:var(--muted);font-weight:600;margin-right:2px;}
25515 .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;}
25516 @media (max-height: 1100px) {
25517 .page{padding-top:10px;}
25518 .hero{margin-bottom:10px;}
25519 .hero-logo{width:54px;height:60px;}
25520 .hero-logo-shadow{width:42px;}
25521 .hero-title{font-size:28px;}
25522 .hero-subtitle{font-size:13px;}
25523 .card-sections{gap:12px;margin-bottom:6px;}
25524 .card-section-grid-2,.card-section-grid-3{gap:10px;}
25525 .action-card{padding:8px 15px 8px;}
25526 .action-card-icon{width:34px;height:34px;border-radius:10px;margin-bottom:6px;}
25527 .action-card-icon svg{width:18px;height:18px;}
25528 .action-card-title{font-size:13px;}
25529 .action-card-desc{font-size:11px;margin-bottom:6px;}
25530 .action-card-cta{font-size:11px;}
25531 .ac-right-row{font-size:11px;}
25532 .divider{margin:14px 0;}
25533 .info-strip{gap:7px;margin-bottom:8px;}
25534 .info-chip{padding:7px 10px;}
25535 .info-chip-val{font-size:13px;}
25536 .info-chip-label{font-size:9px;}
25537 .site-footer{padding:8px 24px;font-size:12px;}
25538 .lan-local-hint{margin-top:8px;}
25539 }
25540 @media (max-height: 850px) {
25541 .page{padding-top:6px;}
25542 .hero{margin-bottom:6px;}
25543 .hero-logo{width:42px;height:46px;}
25544 .hero-title{font-size:22px;}
25545 .hero-subtitle{font-size:12px;}
25546 .card-sections{gap:10px;}
25547 .action-card-desc{margin-bottom:4px;}
25548 .divider{margin:8px 0;}
25549 .info-strip{margin-bottom:6px;}
25550 .lan-local-hint{margin-top:10px;}
25551 }
25552 </style>
25553</head>
25554<body>
25555 <div class="background-watermarks" aria-hidden="true">
25556 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25557 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25558 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25559 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25560 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25561 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25562 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
25563 </div>
25564 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
25565 <div class="top-nav">
25566 <div class="top-nav-inner">
25567 <a class="brand" href="/">
25568 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
25569 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
25570 </a>
25571 <div class="nav-right">
25572 <a class="nav-pill sx-8c38ef73" href="/" >Home</a>
25573 <div class="nav-dropdown">
25574 <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>
25575 <div class="nav-dropdown-menu">
25576 <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>
25577 </div>
25578 </div>
25579 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
25580 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
25581 <div class="nav-dropdown">
25582 <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>
25583 <div class="nav-dropdown-menu">
25584 <a href="/code-ownership"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>Code Ownership</a>
25585 <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>
25586 </div>
25587 </div>
25588 <div class="server-status-wrap" id="server-status-wrap">
25589 <div class="nav-pill server-online-pill" id="server-status-pill">
25590 <span class="status-dot" id="status-dot"></span>
25591 <span id="server-status-label">{% if server_mode %}Server{% else %}Local{% endif %}</span>
25592 <span class="sx-d60f2ef3" id="server-ping-ms" ></span>
25593 </div>
25594 <div class="server-status-tip">
25595 {% if server_mode %}OxideSLOC is running in server mode — accessible on your LAN.{% else %}OxideSLOC is running locally — only accessible from this machine.{% endif %}
25596 <span class="sx-238af6bc" id="server-tip-ping" ></span>
25597 </div>
25598 </div>
25599 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
25600 <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>
25601 </button>
25602 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
25603 <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>
25604 <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>
25605 </button>
25606 </div>
25607 </div>
25608 </div>
25609
25610 <div class="page">
25611 <div class="hero">
25612 <div class="hero-logo-wrap" id="hero-logo-wrap">
25613 <img class="hero-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
25614 </div>
25615 <div class="hero-logo-shadow"></div>
25616 <div class="hero-title-wrap">
25617 <div class="hero-title-aura" aria-hidden="true"></div>
25618 <h1 class="hero-title" id="hero-title">OxideSLOC</h1>
25619 </div>
25620 <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>
25621 </div>
25622
25623 <div class="card-sections">
25624
25625 <div>
25626 <div class="card-section-label">Analysis</div>
25627 <div class="card-section-grid-2">
25628 <a class="action-card scan card-split" href="/scan-setup">
25629 <div class="action-card-left">
25630 <div class="action-card-icon">
25631 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
25632 </div>
25633 <div class="action-card-title">Scan Project</div>
25634 <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>
25635 <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>
25636 </div>
25637 <div class="action-card-sep"></div>
25638 <div class="action-card-right">
25639 <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>
25640 <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>
25641 <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>
25642 <div class="ac-right-stat" id="acp-scan-stat"></div>
25643 </div>
25644 </a>
25645 <a class="action-card test-metrics card-split" href="/test-metrics">
25646 <div class="action-card-left">
25647 <div class="action-card-icon">
25648 <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>
25649 </div>
25650 <div class="action-card-title">Test Metrics</div>
25651 <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>
25652 <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>
25653 </div>
25654 <div class="action-card-sep"></div>
25655 <div class="action-card-right">
25656 <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>
25657 <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>
25658 <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>
25659 <div class="ac-right-stat" id="acp-test-stat"></div>
25660 </div>
25661 </a>
25662 </div>
25663 </div>
25664
25665 <div>
25666 <div class="card-section-label">Reports & Insights</div>
25667 <div class="card-section-grid-4">
25668 <a class="action-card view" href="/view-reports">
25669 <div class="action-card-icon">
25670 <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
25671 </div>
25672 <div class="action-card-title">View Reports</div>
25673 <p class="action-card-desc">Browse recorded scans, open HTML reports, and review historical metrics — code, comments, blank lines, and git branch info.</p>
25674 <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>
25675 </a>
25676 <a class="action-card compare" href="/compare-scans">
25677 <div class="action-card-icon">
25678 <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>
25679 </div>
25680 <div class="action-card-title">Compare Scans</div>
25681 <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>
25682 <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>
25683 </a>
25684 <a class="action-card trend" href="/trend-reports">
25685 <div class="action-card-icon">
25686 <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>
25687 </div>
25688 <div class="action-card-title">Trend Report</div>
25689 <p class="action-card-desc">Visualize how SLOC, comments, and blank lines evolve over time. Spot regressions and chart the full scan history.</p>
25690 <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>
25691 </a>
25692 <a class="action-card ownership" href="/code-ownership">
25693 <div class="action-card-icon">
25694 <svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>
25695 </div>
25696 <div class="action-card-title">Code Ownership</div>
25697 <p class="action-card-desc">Attribute every line to its author via git blame — per-author breakdowns, a contributor leaderboard, and hotspot ownership.</p>
25698 <span class="action-card-cta">View ownership <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>
25699 </a>
25700 </div>
25701 </div>
25702
25703 <div>
25704 <div class="card-section-label">Developer Tools</div>
25705 <div class="card-section-grid-2">
25706 <a class="action-card git-tools card-split" href="/git-browser">
25707 <div class="action-card-left">
25708 <div class="action-card-icon">
25709 <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>
25710 </div>
25711 <div class="action-card-title">Git Browser</div>
25712 <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>
25713 <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>
25714 </div>
25715 <div class="action-card-sep"></div>
25716 <div class="action-card-right">
25717 <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>
25718 <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>
25719 <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>
25720 </div>
25721 </a>
25722 <a class="action-card automation card-split" href="/integrations">
25723 <div class="action-card-left">
25724 <div class="action-card-icon">
25725 <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>
25726 </div>
25727 <div class="action-card-title">Integrations</div>
25728 <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>
25729 <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>
25730 </div>
25731 <div class="action-card-sep"></div>
25732 <div class="action-card-right">
25733 <div class="ac-badges-grid">
25734 <span class="ac-badge github" id="acp-gh">GitHub</span>
25735 <span class="ac-badge gitlab" id="acp-gl">GitLab</span>
25736 <span class="ac-badge bitbucket" id="acp-bb">Bitbucket</span>
25737 <span class="ac-badge confluence" id="acp-cf">Confluence</span>
25738 </div>
25739 <div class="ac-right-stat" id="acp-int-stat"></div>
25740 </div>
25741 </a>
25742 </div>
25743 </div>
25744
25745 </div>
25746
25747 {% if server_mode %}
25748 <div class="lan-card server">
25749 <div class="lan-card-header">
25750 <span class="lan-badge">LAN server</span>
25751 Accessible on your network
25752 </div>
25753 {% if let Some(ip) = lan_ip %}
25754 <div class="lan-url-row">
25755 <code class="lan-url" id="lan-url-val">http://{{ ip }}:{{ port }}</code>
25756 <button class="lan-copy-btn" id="lan-copy-btn" title="Copy URL">
25757 <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>
25758 Copy URL
25759 </button>
25760 </div>
25761 <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>
25762 {% if has_api_key %}
25763 <div class="lan-auth-row">curl -H "Authorization: Bearer $SLOC_API_KEY" http://{{ ip }}:{{ port }}/healthz</div>
25764 {% endif %}
25765 {% else %}
25766 <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>
25767 {% endif %}
25768 </div>
25769 {% endif %}
25770
25771 <div class="divider"></div>
25772
25773 <div class="info-strip">
25774 <div class="info-chip">
25775 <div class="info-chip-tip">C · C++ · Rust · Go · Python · Java · Kotlin · Swift<br>TypeScript · Zig · Haskell · Elixir · and 48 more</div>
25776 <div class="chip-slide">
25777 <div class="info-chip-val">60</div>
25778 <div class="info-chip-label">Languages</div>
25779 </div>
25780 </div>
25781 <div class="info-chip">
25782 <div class="info-chip-tip">Single binary — no runtime, no daemon,<br>no install beyond the executable</div>
25783 <div class="chip-slide">
25784 <div class="info-chip-val">100%</div>
25785 <div class="info-chip-label">Self-contained</div>
25786 </div>
25787 </div>
25788 <div class="info-chip">
25789 <div class="info-chip-tip">Self-contained HTML reports with light/dark theme<br>— shareable without a server. PDF via headless Chromium (CLI).</div>
25790 <div class="chip-slide">
25791 <div class="info-chip-val">HTML+PDF</div>
25792 <div class="info-chip-label">Exportable reports</div>
25793 </div>
25794 </div>
25795 <div class="info-chip">
25796 <div class="info-chip-tip">GitHub, GitLab, and Bitbucket push events<br>trigger scans automatically via webhook</div>
25797 <div class="chip-slide">
25798 <div class="info-chip-val">Webhook</div>
25799 <div class="info-chip-label">3 platforms</div>
25800 </div>
25801 </div>
25802 <div class="info-chip">
25803 <div class="info-chip-tip">Physical SLOC counted per<br>IEEE Std 1045-1992 Software Productivity Metrics</div>
25804 <div class="chip-slide">
25805 <div class="info-chip-val">IEEE</div>
25806 <div class="info-chip-label">1045-1992</div>
25807 </div>
25808 </div>
25809 </div>
25810
25811 {% if lan_ip.is_none() %}
25812 <div class="lan-local-hint">
25813 <strong>Want teammates on the same network to access this?</strong><br>
25814 Relaunch in server mode: <code>oxide-sloc serve --server</code> or <code>bash scripts/serve-server.sh</code>
25815 </div>
25816 {% endif %}
25817 </div>
25818
25819 <footer class="site-footer">
25820 local code analysis - metrics, history and reports
25821 · <em class="footer-mode sx-e01b0d98" id="footer-mode" >oxide-sloc v{{ version }} — Mode: Local</em>
25822 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
25823 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
25824 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
25825 · <a href="/report-bug" rel="noopener">Report a Bug</a> · <a href="/api-docs" rel="noopener">REST API</a>
25826 </footer>
25827
25828 <script nonce="{{ csp_nonce }}">
25829 (function () {
25830 var storageKey = 'oxide-sloc-theme';
25831 var body = document.body;
25832 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
25833 var toggle = document.getElementById('theme-toggle');
25834 if (toggle) toggle.addEventListener('click', function () {
25835 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
25836 body.classList.toggle('dark-theme', next === 'dark');
25837 try { localStorage.setItem(storageKey, next); } catch(e) {}
25838 });
25839 var copyBtn = document.getElementById('lan-copy-btn');
25840 if (copyBtn) copyBtn.addEventListener('click', function() {
25841 var btn = this;
25842 var el = document.getElementById('lan-url-val');
25843 if (!el) return;
25844 var url = el.textContent.trim();
25845 if (navigator.clipboard) {
25846 navigator.clipboard.writeText(url).then(function() {
25847 var orig = btn.innerHTML;
25848 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!';
25849 setTimeout(function() { btn.innerHTML = orig; }, 1800);
25850 });
25851 }
25852 });
25853 (function randomizeWatermarks() {
25854 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
25855 if (!wms.length) return;
25856 var placed = [];
25857 function tooClose(top, left) {
25858 for (var i = 0; i < placed.length; i++) {
25859 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
25860 if (dt < 16 && dl < 12) return true;
25861 }
25862 return false;
25863 }
25864 function pick(leftBand) {
25865 for (var attempt = 0; attempt < 50; attempt++) {
25866 var top = Math.random() * 88 + 2;
25867 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
25868 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
25869 }
25870 var top = Math.random() * 88 + 2;
25871 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
25872 placed.push([top, left]); return [top, left];
25873 }
25874 var half = Math.floor(wms.length / 2);
25875 wms.forEach(function (img, i) {
25876 var pos = pick(i < half);
25877 var size = Math.floor(Math.random() * 100 + 120);
25878 var rot = (Math.random() * 360).toFixed(1);
25879 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
25880 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;
25881 });
25882 })();
25883
25884 (function spawnCodeParticles() {
25885 var container = document.getElementById('code-particles');
25886 if (!container) return;
25887 var snippets = ['NUM sloc','fn analyze()','code_lines','0 mixed','blanks: NUM','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','NUM physical','files: NUM','NUM comments','cargo build','Ok(run)','Vec<GEN>','match lang','fn main()','.rs .go .py','sloc_core','render_html','NUM code','HashMap<GEN, GN2>','Option<GEN>','&str','author AUT','last_change','ratio 0.CPX','complexity CPX','tokens: NUM','?; // try','let owner','commits: NUM','#[tokio::test]','assert_eq!','PCT% cover','blame AUT','churn PCT%','HEAD~CPX','dedup PCT%','cyclomatic CPX','impl Iterator','.await','+NUM -NM2','ownership','Span<GEN>','pub struct','trait Lang','unsafe','mixed: NUM','anyhow::Result','fn detect()','PathBuf','+NUM loc','coverage PCT%','physical: NUM','ULOC NUM','dup PCT%','let mut run']; var __GT=['Author','String','Token','FileRecord','Lang','u32','usize','PathBuf','Commit','Line','Span','Metric','Record','u64','char','Utf8','Ratio','Blame']; var __AN=['nima','core','web','cli','git','lang','ci','bot','sloc'];
25888 var count = 44;
25889 for (var i = 0; i < count; i++) {
25890 (function(idx) {
25891 var el = document.createElement('span');
25892 el.className = 'code-particle';
25893 var text = snippets[idx % snippets.length].split('NUM').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('NM2').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('GEN').join(__GT[Math.floor(Math.random()*__GT.length)]).split('GN2').join(__GT[Math.floor(Math.random()*__GT.length)]).split('AUT').join(__AN[Math.floor(Math.random()*__AN.length)]).split('CPX').join(''+Math.floor(Math.random()*40+1)).split('PCT').join(''+Math.floor(Math.random()*44+1));
25894 el.textContent = text;
25895 var left = Math.random() * 94 + 2;
25896 var top = Math.random() * 88 + 6;
25897 var dur = (Math.random() * 10 + 9).toFixed(1);
25898 var delay = (Math.random() * 18).toFixed(1);
25899 var rot = (Math.random() * 26 - 13).toFixed(1);
25900 var op = (Math.random() * 0.108 + 0.072).toFixed(3);
25901 el.style.left = left.toFixed(1) + '%';
25902 el.style.top = top.toFixed(1) + '%';
25903 el.style.setProperty('--rot', rot + 'deg');
25904 el.style.setProperty('--op', op);
25905 el.style.animationDuration = dur + 's';
25906 el.style.animationDelay = '-' + delay + 's';
25907 container.appendChild(el);
25908 })(i);
25909 }
25910 })();
25911 (function heroAnimations() {
25912 var sub = document.getElementById('hero-subtitle');
25913 if (sub) {
25914 var full = sub.textContent.trim();
25915 sub.textContent = '';
25916 sub.style.opacity = '1';
25917 var cursor = document.createElement('span');
25918 cursor.className = 'hero-cursor';
25919 sub.appendChild(cursor);
25920 var i = 0;
25921 setTimeout(function() {
25922 var iv = setInterval(function() {
25923 if (i < full.length) {
25924 sub.insertBefore(document.createTextNode(full[i]), cursor);
25925 i++;
25926 } else {
25927 clearInterval(iv);
25928 setTimeout(function() {
25929 cursor.style.transition = 'opacity 1s ease';
25930 cursor.style.opacity = '0';
25931 setTimeout(function() { if (cursor.parentNode) cursor.parentNode.removeChild(cursor); }, 1000);
25932 }, 2400);
25933 }
25934 }, 11);
25935 }, 374);
25936 }
25937 })();
25938 (function logoBob() {
25939 var logo = document.querySelector('.hero-logo');
25940 var shadow = document.querySelector('.hero-logo-shadow');
25941 if (!logo) return;
25942 var cycleStart = null, cycleDur = 3600;
25943 var peakY = -14, peakScale = 1.07, peakRot = 0;
25944 function newCycle() {
25945 cycleDur = 3000 + Math.random() * 1840;
25946 peakY = -(9 + Math.random() * 13.8);
25947 peakScale = 1.04 + Math.random() * 0.081;
25948 peakRot = (Math.random() * 11.5 - 5.75);
25949 }
25950 function ease(t) { return t < 0.5 ? 2*t*t : -1+(4-2*t)*t; }
25951 newCycle();
25952 function frame(ts) {
25953 if (cycleStart === null) cycleStart = ts;
25954 var t = (ts - cycleStart) / cycleDur;
25955 if (t >= 1) { cycleStart = ts; t = 0; newCycle(); }
25956 var phase = t < 0.4 ? ease(t / 0.4) : t < 0.6 ? 1 : ease(1 - (t - 0.6) / 0.4);
25957 var y = peakY * phase;
25958 var sc = 1 + (peakScale - 1) * phase;
25959 var rot = peakRot * Math.sin(Math.PI * phase);
25960 logo.style.transform = 'translateY('+y.toFixed(2)+'px) scale('+sc.toFixed(4)+') rotate('+rot.toFixed(2)+'deg)';
25961 if (shadow) {
25962 shadow.style.transform = 'scaleX('+(1 - 0.3*phase).toFixed(4)+')';
25963 shadow.style.opacity = (0.55 - 0.37*phase).toFixed(3);
25964 }
25965 requestAnimationFrame(frame);
25966 }
25967 requestAnimationFrame(frame);
25968 })();
25969 (function mouseEffects() {
25970 var heroTitle = document.getElementById('hero-title');
25971 var raf = null, mx = window.innerWidth / 2, my = window.innerHeight / 2;
25972 function tick() {
25973 raf = null;
25974 if (heroTitle) {
25975 var r = heroTitle.getBoundingClientRect();
25976 var dx = (mx - (r.left + r.width / 2)) / (window.innerWidth / 2);
25977 var dy = (my - (r.top + r.height / 2)) / (window.innerHeight / 2);
25978 heroTitle.style.transform = 'perspective(800px) rotateX('+(-dy*7.8).toFixed(2)+'deg) rotateY('+(dx*18.2).toFixed(2)+'deg)';
25979 }
25980 }
25981 document.addEventListener('mousemove', function(e) {
25982 mx = e.clientX; my = e.clientY;
25983 if (!raf) raf = requestAnimationFrame(tick);
25984 });
25985 document.addEventListener('mouseleave', function() {
25986 if (heroTitle) {
25987 heroTitle.style.transition = 'transform 0.5s ease';
25988 heroTitle.style.transform = '';
25989 setTimeout(function() { heroTitle.style.transition = ''; }, 500);
25990 }
25991 });
25992 document.querySelectorAll('.action-card').forEach(function(card) {
25993 card.addEventListener('mousemove', function(e) {
25994 var rect = card.getBoundingClientRect();
25995 var dx = (e.clientX - (rect.left + rect.width / 2)) / (rect.width / 2);
25996 var dy = (e.clientY - (rect.top + rect.height / 2)) / (rect.height / 2);
25997 card.style.transition = 'transform 0.08s linear,box-shadow 0.18s ease,border-color 0.18s ease';
25998 card.style.transform = 'perspective(700px) rotateX('+(-dy*4.2).toFixed(2)+'deg) rotateY('+(dx*4.2).toFixed(2)+'deg) translateY(-5px) scale(1.03)';
25999 });
26000 card.addEventListener('mouseleave', function() {
26001 card.style.transition = '';
26002 card.style.transform = '';
26003 });
26004 });
26005 })();
26006 (function chipSlideshow() {
26007 var slides = [
26008 [{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'}],
26009 [{v:'100%',l:'Self-contained'},{v:'Zero',l:'Dependencies'},{v:'Single',l:'Binary'}],
26010 [{v:'HTML+PDF',l:'Exportable reports'},{v:'Light+Dark',l:'Themed'},{v:'Offline',l:'No server needed'}],
26011 [{v:'Webhook',l:'3 platforms'},{v:'GitHub + GitLab',l:'+ Bitbucket'},{v:'Auto-scan',l:'On every push'}],
26012 [{v:'IEEE',l:'1045-1992'},{v:'Physical',l:'SLOC standard'},{v:'Blank lines',l:'Configurable'}]
26013 ];
26014 var chips = Array.prototype.slice.call(document.querySelectorAll('.info-chip'));
26015 var indices = [0,0,0,0,0];
26016 var paused = [false,false,false,false,false];
26017 chips.forEach(function(chip, i) {
26018 chip.addEventListener('mouseenter', function() { paused[i] = true; });
26019 chip.addEventListener('mouseleave', function() { paused[i] = false; });
26020 });
26021 function advance(i) {
26022 if (paused[i]) return;
26023 var chip = chips[i];
26024 var inner = chip.querySelector('.chip-slide');
26025 if (!inner) return;
26026 inner.classList.add('fading');
26027 setTimeout(function() {
26028 indices[i] = (indices[i] + 1) % slides[i].length;
26029 var s = slides[i][indices[i]];
26030 chip.querySelector('.info-chip-val').textContent = s.v;
26031 chip.querySelector('.info-chip-label').textContent = s.l;
26032 inner.classList.remove('fading');
26033 }, 720);
26034 }
26035 setInterval(function() {
26036 chips.forEach(function(chip, i) { advance(i); });
26037 }, 6000);
26038 })();
26039 (function cardLiveData() {
26040 fetch('/api/project-history').then(function(r){return r.json();}).then(function(d){
26041 var el = document.getElementById('acp-scan-stat');
26042 if(el && d.scan_count) el.textContent = d.scan_count + ' scan' + (d.scan_count === 1 ? '' : 's') + ' in history';
26043 }).catch(function(){});
26044 fetch('/api/metrics/latest').then(function(r){return r.ok ? r.json() : null;}).then(function(d){
26045 var el = document.getElementById('acp-test-stat');
26046 if(el && d && d.summary && d.summary.test_count) el.textContent = fmt(d.summary.test_count) + ' tests in last scan';
26047 }).catch(function(){});
26048 fetch('/api/schedules').then(function(r){return r.json();}).then(function(d){
26049 var sc = (d.schedules || []).filter(function(s){return s.enabled !== false;});
26050 var providers = sc.map(function(s){return (s.provider || '').toLowerCase();});
26051 if(providers.indexOf('github') >= 0) { var e = document.getElementById('acp-gh'); if(e) e.classList.add('active'); }
26052 if(providers.indexOf('gitlab') >= 0) { var e = document.getElementById('acp-gl'); if(e) e.classList.add('active'); }
26053 if(providers.indexOf('bitbucket') >= 0) { var e = document.getElementById('acp-bb'); if(e) e.classList.add('active'); }
26054 var stat = document.getElementById('acp-int-stat');
26055 if(stat && sc.length) stat.textContent = sc.length + ' webhook' + (sc.length === 1 ? '' : 's') + ' configured';
26056 }).catch(function(){});
26057 fetch('/api/confluence/config').then(function(r){return r.json();}).then(function(d){
26058 if(d.configured) { var e = document.getElementById('acp-cf'); if(e) e.classList.add('active'); }
26059 }).catch(function(){});
26060 })();
26061 })();
26062 </script>
26063 <script nonce="{{ csp_nonce }}">
26064 (function(){
26065 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'}];
26066 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);});}
26067 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26068 function init(){
26069 var btn=document.getElementById('settings-btn');if(!btn)return;
26070 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
26071 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 class="sx-dec3b281" ><div class="settings-modal-label sx-c500155b" >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>';
26072 document.body.appendChild(m);
26073 var g=document.getElementById('scheme-grid');
26074 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);});
26075 var cl=document.getElementById('settings-close');
26076 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);});})();
26077 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');});
26078 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
26079 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
26080 }
26081 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26082 }());
26083 </script>
26084 <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>
26085</body>
26086</html>
26087"##,
26088 ext = "html"
26089)]
26090struct SplashTemplate {
26091 csp_nonce: String,
26092 server_mode: bool,
26093 lan_ip: Option<String>,
26094 port: u16,
26095 version: &'static str,
26096 has_api_key: bool,
26097}
26098
26099#[derive(Template)]
26102#[template(
26103 source = r##"
26104<!doctype html>
26105<html lang="en">
26106<head>
26107 <meta charset="utf-8">
26108 <meta name="viewport" content="width=device-width, initial-scale=1">
26109 <title>OxideSLOC — Start a Scan</title>
26110 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26111 <link rel="stylesheet" href="/static/app.css">
26112 <script src="/static/app.js"></script>
26113 <style nonce="{{ csp_nonce }}">
26114 :root {
26115 --radius:18px; --bg:#f5efe8; --surface:#ffffff; --surface-2:#fbf7f2;
26116 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
26117 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
26118 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
26119 --shadow-strong:0 28px 56px rgba(77,44,20,0.20);
26120 }
26121 body.dark-theme {
26122 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
26123 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
26124 }
26125 *{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;}
26126 .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);}
26127 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
26128 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;flex-shrink:0;}
26129 .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));}
26130 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
26131 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
26132 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
26133 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
26134 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
26135 @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; } }
26136 .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;}
26137 a.nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
26138 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
26139 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
26140 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
26141 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
26142 .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;}
26143 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26144 .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);}
26145 .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;}
26146 .settings-close:hover{color:var(--text);background:var(--surface-2);}
26147 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26148 .settings-modal-body{padding:14px 16px 16px;}
26149 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26150 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26151 .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;}
26152 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26153 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26154 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26155 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26156 .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;}
26157 .tz-select:focus{border-color:var(--oxide);}
26158 .page{max-width:1104px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
26159 .page-header{text-align:center;margin-bottom:16px;}
26160 .page-header h1{font-size:34px;font-weight:900;letter-spacing:-0.03em;margin:0 0 8px;}
26161 .page-header p{font-size:15px;color:var(--muted);line-height:1.6;white-space:nowrap;margin:0 auto;}
26162 /* Cards */
26163 .option-grid{display:flex;flex-direction:column;gap:16px;padding-top:16px;}
26164 .option-card-wrap{position:relative;}
26165 .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;}
26166 .option-card:hover{transform:translateY(-5px) scale(1.03);border-color:var(--oxide-2);box-shadow:var(--shadow-strong);}
26167 @keyframes cardRise{from{opacity:0;}to{opacity:1;}}
26168 @media(prefers-reduced-motion:reduce){.option-card{animation:none;}}
26169 .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;}
26170 .option-icon{transition:transform 0.22s cubic-bezier(.34,1.56,.64,1);}
26171 .option-card:hover .option-icon{transform:rotate(-8deg) scale(1.12);}
26172 #recent-card{flex-direction:column;align-items:stretch;gap:0;}
26173 .card-top-row{display:flex;align-items:center;gap:20px;}
26174 /* Two-column layout inside each card */
26175 .card-body{flex:1;min-width:0;display:grid;grid-template-columns:1fr 220px;gap:20px;align-items:center;padding-left:12px;}
26176 .card-left{display:flex;align-items:flex-start;min-width:0;}
26177 .option-icon{width:56px;height:56px;border-radius:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
26178 .option-icon svg{width:28px;height:28px;stroke:#fff;fill:none;stroke-width:2;}
26179 .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);}
26180 .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);}
26181 .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);}
26182 .card-text{min-width:0;}
26183 .option-title{font-size:17px;font-weight:800;letter-spacing:-0.02em;margin:0 0 9px;}
26184 .option-desc{font-size:13px;color:var(--muted);line-height:1.55;margin:0 0 10px;}
26185 .feature-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;}
26186 .feature-list li{font-size:12px;color:var(--muted-2);display:flex;align-items:center;gap:7px;}
26187 .feature-list li::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--oxide);opacity:0.7;flex:0 0 auto;}
26188 /* Right CTA column */
26189 .card-right{display:flex;flex-direction:column;align-items:stretch;gap:10px;}
26190 .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;}
26191 /* Re-scan count badge */
26192 .rescan-count-box{text-align:center;padding:12px 10px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px;}
26193 .rescan-count-num{font-size:28px;font-weight:900;color:var(--oxide);line-height:1;}
26194 .rescan-count-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-top:5px;}
26195 body.dark-theme .rescan-count-box{background:var(--surface-2);border-color:var(--line-strong);}
26196 .btn:hover{transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,0.14);}
26197 .btn-primary{background:linear-gradient(135deg,#e07b3a,#b85028);color:#fff;}
26198 .btn-secondary{background:var(--surface-2);color:var(--oxide-2);border:1.5px solid var(--line-strong);}
26199 body.dark-theme .btn-secondary{color:var(--oxide);}
26200 .btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.4;}
26201 .card-tip{font-size:11px;color:var(--muted);text-align:center;margin:0;line-height:1.5;}
26202 /* File input overlay — must be full-width so it aligns with other card-right buttons */
26203 .file-input-wrap{position:relative;width:100%;}
26204 .file-input-wrap .btn{width:100%;}
26205 .file-input-wrap input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%;}
26206 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26207 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
26208 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
26209 .code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
26210 @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));}}
26211 /* Recent list (card 3 — full-width section below header) */
26212 .section-divider{height:1px;background:var(--line);margin:16px 0 14px;}
26213 .recent-list{display:flex;flex-direction:column;gap:8px;}
26214 .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;}
26215 .recent-item:hover{border-color:var(--oxide-2);background:var(--surface);}
26216 .recent-item-info{flex:1;min-width:0;}
26217 .recent-item-label{font-size:13px;font-weight:700;margin:0 0 2px;}
26218 .recent-item-meta{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
26219 .recent-arrow{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;flex:0 0 auto;}
26220 .no-recent-note{font-size:12px;color:var(--muted);font-style:italic;padding:6px 0;}
26221 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
26222 .site-footer a{color:var(--muted);}
26223 @media(max-width:680px){
26224 .card-body{grid-template-columns:1fr;}
26225 .card-right{flex-direction:row;flex-wrap:wrap;}
26226 .btn{flex:1;}
26227 }
26228 .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;}
26229 .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;}
26230 .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;}
26231 </style>
26232</head>
26233<body>
26234 <div class="background-watermarks" aria-hidden="true">
26235 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26236 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26237 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26238 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26239 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26240 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26241 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
26242 </div>
26243 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26244 <div class="top-nav">
26245 <div class="top-nav-inner">
26246 <a class="brand" href="/">
26247 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
26248 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
26249 </a>
26250 <div class="nav-right">
26251 <a class="nav-pill" href="/">Home</a>
26252 <div class="nav-dropdown">
26253 <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>
26254 <div class="nav-dropdown-menu">
26255 <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>
26256 </div>
26257 </div>
26258 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
26259 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
26260 <div class="nav-dropdown">
26261 <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>
26262 <div class="nav-dropdown-menu">
26263 <a href="/code-ownership"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>Code Ownership</a>
26264 <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>
26265 </div>
26266 </div>
26267 <div class="server-status-wrap" id="server-status-wrap">
26268 <div class="nav-pill server-online-pill" id="server-status-pill">
26269 <span class="status-dot" id="status-dot"></span>
26270 <span id="server-status-label">Server</span>
26271 <span class="sx-d60f2ef3" id="server-ping-ms" ></span>
26272 </div>
26273 <div class="server-status-tip">
26274 OxideSLOC is running — accessible on your network.
26275 <span class="sx-238af6bc" id="server-tip-ping" ></span>
26276 </div>
26277 </div>
26278 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
26279 <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>
26280 </button>
26281 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
26282 <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>
26283 <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>
26284 </button>
26285 </div>
26286 </div>
26287 </div>
26288
26289 <div class="page">
26290 <div class="page-header">
26291 <h1>How would you like to scan?</h1>
26292 <p>Start fresh with the full wizard, load saved settings from a config file, or quickly re-run a recent scan.</p>
26293 </div>
26294
26295 <div class="option-grid">
26296
26297 <!-- Option 1: New scan -->
26298 <div class="option-card-wrap">
26299 <div class="option-card">
26300 <div class="option-icon new-scan">
26301 <svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
26302 </div>
26303 <div class="card-body">
26304 <div class="card-left">
26305 <div class="card-text">
26306 <div class="option-title">Start a new scan</div>
26307 <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>
26308 <ul class="feature-list">
26309 <li>Live project scope preview before you run</li>
26310 <li>4 IEEE 1045-1992 counting modes with interactive examples</li>
26311 <li>HTML, PDF, and JSON output — your choice</li>
26312 </ul>
26313 </div>
26314 </div>
26315 <div class="card-right">
26316 <a class="btn btn-primary" href="/scan">
26317 Configure & scan
26318 <svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>
26319 </a>
26320 <p class="card-tip">Full 4-step setup · all options</p>
26321 </div>
26322 </div>
26323 </div>
26324 </div>
26325
26326 <!-- Option 2: Load from config file -->
26327 <div class="option-card-wrap">
26328 <div class="option-card">
26329 <div class="option-icon load-config">
26330 <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>
26331 </div>
26332 <div class="card-body">
26333 <div class="card-left">
26334 <div class="card-text">
26335 <div class="option-title">Load a saved config</div>
26336 <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>
26337 <ul class="feature-list">
26338 <li>All 15 settings restored from the file</li>
26339 <li>Fully editable — change path or output dir</li>
26340 <li>Works with any scan-config.json</li>
26341 </ul>
26342 </div>
26343 </div>
26344 <div class="card-right">
26345 <div class="file-input-wrap">
26346 <button class="btn btn-secondary" id="load-config-btn" type="button">
26347 <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>
26348 Choose config file
26349 </button>
26350 <input type="file" accept=".json,application/json" id="config-file-input" title="Select a scan-config.json file">
26351 </div>
26352 <p class="card-tip" id="config-file-name">Exported after every scan</p>
26353 </div>
26354 </div>
26355 </div>
26356 </div>
26357
26358 <!-- Option 3: Re-scan recent project -->
26359 <div class="option-card-wrap">
26360 <div class="option-card" id="recent-card">
26361 <div class="card-top-row">
26362 <div class="option-icon rescan">
26363 <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>
26364 </div>
26365 <div class="card-body">
26366 <div class="card-left">
26367 <div class="card-text">
26368 <div class="option-title">Re-scan a recent project</div>
26369 <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>
26370 <ul class="feature-list">
26371 <li>All 15+ settings restored from the saved config</li>
26372 <li>Path and output dir are editable before running</li>
26373 <li>Only scans with a saved config appear here</li>
26374 </ul>
26375 </div>
26376 </div>
26377 <div class="card-right">
26378 <div class="rescan-count-box">
26379 <div class="rescan-count-num" id="rescan-count-num">—</div>
26380 <div class="rescan-count-label">saved configs</div>
26381 </div>
26382 <a class="btn btn-secondary" href="/view-reports">
26383 <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>
26384 View all runs
26385 </a>
26386 <p class="card-tip">Opens run history</p>
26387 </div>
26388 </div>
26389 </div>
26390 <div class="section-divider"></div>
26391 <div class="recent-list" id="recent-list">
26392 <p class="no-recent-note" id="no-recent-note">No recent scans yet. Complete a scan and it will appear here automatically.</p>
26393 </div>
26394 </div>
26395 </div>
26396
26397 </div>
26398 </div>
26399
26400 <footer class="site-footer">
26401 local code analysis - metrics, history and reports
26402 · <em class="footer-mode sx-e01b0d98" id="footer-mode" >oxide-sloc v{{ version }} — Mode: Local</em>
26403 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
26404 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
26405 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
26406 · <a href="/report-bug" rel="noopener">Report a Bug</a> · <a href="/api-docs" rel="noopener">REST API</a>
26407 </footer>
26408
26409 <script nonce="{{ csp_nonce }}">
26410 (function () {
26411 var storageKey = 'oxide-sloc-theme';
26412 var body = document.body;
26413 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
26414 var toggle = document.getElementById('theme-toggle');
26415 if (toggle) toggle.addEventListener('click', function () {
26416 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
26417 body.classList.toggle('dark-theme', next === 'dark');
26418 try { localStorage.setItem(storageKey, next); } catch(e) {}
26419 });
26420
26421 (function randomizeWatermarks() {
26422 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
26423 if (!wms.length) return;
26424 var placed = [];
26425 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; }
26426 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]; }
26427 var half = Math.floor(wms.length / 2);
26428 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; });
26429 })();
26430 (function spawnCodeParticles() {
26431 var container = document.getElementById('code-particles');
26432 if (!container) return;
26433 var snippets = ['NUM sloc','fn analyze()','code_lines','0 mixed','blanks: NUM','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','NUM physical','files: NUM','NUM comments','cargo build','Ok(run)','Vec<GEN>','match lang','fn main()','.rs .go .py','sloc_core','render_html','NUM code','HashMap<GEN, GN2>','Option<GEN>','&str','author AUT','last_change','ratio 0.CPX','complexity CPX','tokens: NUM','?; // try','let owner','commits: NUM','#[tokio::test]','assert_eq!','PCT% cover','blame AUT','churn PCT%','HEAD~CPX','dedup PCT%','cyclomatic CPX','impl Iterator','.await','+NUM -NM2','ownership','Span<GEN>','pub struct','trait Lang','unsafe','mixed: NUM','anyhow::Result','fn detect()','PathBuf','+NUM loc','coverage PCT%','physical: NUM','ULOC NUM','dup PCT%','let mut run']; var __GT=['Author','String','Token','FileRecord','Lang','u32','usize','PathBuf','Commit','Line','Span','Metric','Record','u64','char','Utf8','Ratio','Blame']; var __AN=['nima','core','web','cli','git','lang','ci','bot','sloc'];
26434 var count = 44;
26435 for (var i = 0; i < count; i++) { (function(idx) { var el = document.createElement('span'); el.className = 'code-particle'; el.textContent = snippets[idx % snippets.length].split('NUM').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('NM2').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('GEN').join(__GT[Math.floor(Math.random()*__GT.length)]).split('GN2').join(__GT[Math.floor(Math.random()*__GT.length)]).split('AUT').join(__AN[Math.floor(Math.random()*__AN.length)]).split('CPX').join(''+Math.floor(Math.random()*40+1)).split('PCT').join(''+Math.floor(Math.random()*44+1)); 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.108 + 0.072).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); }
26436 })();
26437 // Recent scans data injected from server
26438 var recentScans = {{ recent_scans_json|safe }};
26439
26440 function configToParams(cfg) {
26441 var p = new URLSearchParams();
26442 p.set('prefilled', '1');
26443 if (cfg.path) p.set('path', cfg.path);
26444 if (cfg.include_globs) p.set('include_globs', cfg.include_globs);
26445 if (cfg.exclude_globs) p.set('exclude_globs', cfg.exclude_globs);
26446 if (cfg.submodule_breakdown) p.set('submodule_breakdown', 'enabled');
26447 p.set('mixed_line_policy', cfg.mixed_line_policy || 'code_only');
26448 p.set('python_docstrings_as_comments', cfg.python_docstrings_as_comments ? 'on' : 'off');
26449 p.set('generated_file_detection', cfg.generated_file_detection ? 'enabled' : 'disabled');
26450 p.set('minified_file_detection', cfg.minified_file_detection ? 'enabled' : 'disabled');
26451 p.set('vendor_directory_detection', cfg.vendor_directory_detection ? 'enabled' : 'disabled');
26452 if (cfg.include_lockfiles) p.set('include_lockfiles', 'enabled');
26453 p.set('binary_file_behavior', cfg.binary_file_behavior || 'skip');
26454 if (cfg.output_dir) p.set('output_dir', cfg.output_dir);
26455 if (cfg.report_title) p.set('report_title', cfg.report_title);
26456 p.set('generate_html', cfg.generate_html !== false ? 'on' : 'off');
26457 if (cfg.generate_pdf) p.set('generate_pdf', 'on');
26458 if (cfg.continuation_line_policy) p.set('continuation_line_policy', cfg.continuation_line_policy);
26459 if (cfg.blank_in_block_comment_policy) p.set('blank_in_block_comment_policy', cfg.blank_in_block_comment_policy);
26460 p.set('count_compiler_directives', cfg.count_compiler_directives === false ? 'disabled' : 'enabled');
26461 p.set('style_analysis_enabled', cfg.style_analysis_enabled === false ? 'disabled' : 'enabled');
26462 if (cfg.style_col_threshold) p.set('style_col_threshold', String(cfg.style_col_threshold));
26463 if (cfg.style_score_threshold) p.set('style_score_threshold', String(cfg.style_score_threshold));
26464 if (cfg.style_lang_scope) p.set('style_lang_scope', cfg.style_lang_scope);
26465 if (cfg.coverage_file) p.set('coverage_file', cfg.coverage_file);
26466 if (cfg.cocomo_mode) p.set('cocomo_mode', cfg.cocomo_mode);
26467 if (cfg.complexity_alert) p.set('complexity_alert', String(cfg.complexity_alert));
26468 if (cfg.activity_window !== undefined && cfg.activity_window !== null) p.set('activity_window', String(cfg.activity_window));
26469 if (cfg.exclude_duplicates) p.set('exclude_duplicates', 'enabled');
26470 if (cfg.attribution) p.set('attribution', 'enabled');
26471 return p;
26472 }
26473
26474 // Build recent scan list (capped at 3 visible entries)
26475 var list = document.getElementById('recent-list');
26476 var noNote = document.getElementById('no-recent-note');
26477 var hasAny = false;
26478 var MAX_RECENT = 3;
26479 if (Array.isArray(recentScans)) {
26480 var validEntries = recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; });
26481 var shown = 0;
26482 validEntries.forEach(function (entry) {
26483 if (shown >= MAX_RECENT) return;
26484 shown++;
26485 hasAny = true;
26486 var item = document.createElement('div');
26487 item.className = 'recent-item';
26488 item.title = 'Restore all settings and open wizard';
26489 item.innerHTML =
26490 '<div class="recent-item-info">' +
26491 '<div class="recent-item-label">' + escHtml(entry.project_label || 'Unknown project') + '</div>' +
26492 '<div class="recent-item-meta">' + escHtml(entry.path || '') + ' \u00b7 ' + escHtml(entry.timestamp || '') + '</div>' +
26493 '</div>' +
26494 '<svg class="recent-arrow" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></svg>';
26495 item.addEventListener('click', function () {
26496 var params = configToParams(entry.config);
26497 window.location.href = '/scan?' + params.toString();
26498 });
26499 list.appendChild(item);
26500 });
26501 if (validEntries.length > MAX_RECENT) {
26502 var moreEl = document.createElement('div');
26503 moreEl.className = 'recent-more-link';
26504 moreEl.innerHTML = '+' + (validEntries.length - MAX_RECENT) + ' more — <a href="/view-reports">view all runs</a>';
26505 list.appendChild(moreEl);
26506 }
26507 }
26508 if (hasAny && noNote) noNote.style.display = 'none';
26509 // Update count badge
26510 var countEl = document.getElementById('rescan-count-num');
26511 if (countEl) {
26512 var total = Array.isArray(recentScans) ? recentScans.filter(function(e) { return e.config && typeof e.config === 'object'; }).length : 0;
26513 countEl.textContent = total > 0 ? total : '0';
26514 }
26515
26516 // Config file loader
26517 var fileInput = document.getElementById('config-file-input');
26518 var fileName = document.getElementById('config-file-name');
26519 var loadBtn = document.getElementById('load-config-btn');
26520 // Wire the visible button to open the hidden file picker.
26521 if (loadBtn && fileInput) {
26522 loadBtn.addEventListener('click', function () { fileInput.click(); });
26523 }
26524 if (fileInput) {
26525 fileInput.addEventListener('change', function () {
26526 var file = fileInput.files && fileInput.files[0];
26527 if (!file) return;
26528 if (fileName) fileName.textContent = '\u2713 ' + file.name;
26529 var reader = new FileReader();
26530 reader.onload = function (e) {
26531 try {
26532 var cfg = JSON.parse(e.target.result);
26533 if (!cfg || typeof cfg !== 'object') { alert('Invalid config file \u2014 expected a JSON object.'); return; }
26534 var params = configToParams(cfg);
26535 window.location.href = '/scan?' + params.toString();
26536 } catch (err) {
26537 alert('Could not parse config file: ' + err.message);
26538 }
26539 };
26540 reader.readAsText(file);
26541 });
26542 }
26543
26544 function escHtml(s) {
26545 return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
26546 }
26547 })();
26548 </script>
26549 <script nonce="{{ csp_nonce }}">
26550 (function(){
26551 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'}];
26552 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);});}
26553 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
26554 function init(){
26555 var btn=document.getElementById('settings-btn');if(!btn)return;
26556 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
26557 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 class="sx-dec3b281" ><div class="settings-modal-label sx-c500155b" >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>';
26558 document.body.appendChild(m);
26559 var g=document.getElementById('scheme-grid');
26560 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);});
26561 var cl=document.getElementById('settings-close');
26562 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);});})();
26563 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');});
26564 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
26565 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
26566 }
26567 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
26568 }());
26569 </script>
26570 <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]';
26571 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;}
26572 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>
26573</body>
26574</html>
26575"##,
26576 ext = "html"
26577)]
26578struct ScanSetupTemplate {
26579 version: &'static str,
26580 recent_scans_json: String,
26581 csp_nonce: String,
26582}
26583
26584#[derive(Template)]
26585#[template(
26586 source = r##"
26587<!doctype html>
26588<html lang="en">
26589<head>
26590 <meta charset="utf-8">
26591 <meta name="viewport" content="width=device-width, initial-scale=1">
26592 <title>OxideSLOC | {{ report_title }} | Report</title>
26593 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
26594 <link rel="stylesheet" href="/static/app.css">
26595 <script src="/static/app.js"></script>
26596 <style nonce="{{ csp_nonce }}">
26597 :root {
26598 --radius: 18px;
26599 --bg: #f5efe8;
26600 --surface: rgba(255,255,255,0.82);
26601 --surface-2: #fbf7f2;
26602 --surface-3: #efe6dc;
26603 --line: #e6d0bf;
26604 --line-strong: #dcb89f;
26605 --text: #43342d;
26606 --muted: #7b675b;
26607 --muted-2: #a08777;
26608 --nav: #b85d33;
26609 --nav-2: #7a371b;
26610 --accent: #6f9bff;
26611 --accent-2: #4a78ee;
26612 --oxide: #d37a4c;
26613 --oxide-2: #b35428;
26614 --shadow: 0 18px 42px rgba(77, 44, 20, 0.12);
26615 --shadow-strong: 0 22px 48px rgba(77, 44, 20, 0.16);
26616 --success-bg: #e8f5ed;
26617 --success-text: #1a8f47;
26618 --info-bg: #eef3ff;
26619 --info-text: #4467d8;
26620 }
26621
26622 body.dark-theme {
26623 --bg: #1b1511;
26624 --surface: #261c17;
26625 --surface-2: #2d221d;
26626 --surface-3: #372922;
26627 --line: #524238;
26628 --line-strong: #6c5649;
26629 --text: #f5ece6;
26630 --muted: #c7b7aa;
26631 --muted-2: #aa9485;
26632 --nav: #b85d33;
26633 --nav-2: #7a371b;
26634 --accent: #6f9bff;
26635 --accent-2: #4a78ee;
26636 --oxide: #d37a4c;
26637 --oxide-2: #b35428;
26638 --shadow: 0 18px 42px rgba(0,0,0,0.28);
26639 --shadow-strong: 0 22px 48px rgba(0,0,0,0.34);
26640 --success-bg: #163927;
26641 --success-text: #8fe2a8;
26642 --info-bg: #1c2847;
26643 --info-text: #a9c1ff;
26644 }
26645
26646 * { box-sizing: border-box; }
26647 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); }
26648 body { overflow-x: hidden; transition: background 0.18s ease, color 0.18s ease; display: flex; flex-direction: column; }
26649 .background-watermarks { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
26650 .background-watermarks img { position: absolute; opacity: 0.16; filter: blur(0.3px); user-select: none; max-width: none; }
26651 .top-nav, .page { position: relative; z-index: 2; }
26652 .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); }
26653 .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; }
26654 .brand { display: flex; align-items: center; gap: 14px; min-width: 0; text-decoration: none; }
26655 .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)); }
26656 .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; }
26657 .brand-copy { display: flex; flex-direction: column; justify-content: center; min-width: 0; }
26658 .brand-title { margin: 0; color: #fff; font-size: 17px; font-weight: 800; line-height: 1.1; }
26659 .brand-subtitle { color: rgba(255,255,255,0.85); font-size: 12px; line-height: 1.2; margin-top: 2px; }
26660 .nav-project-slot { display:flex; justify-content:center; min-width:0; }
26661 .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; }
26662 .nav-project-label { color: rgba(255,255,255,0.78); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; font-weight: 800; }
26663 .nav-project-value { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
26664 .nav-status { display: flex; align-items: center; justify-content: flex-end; gap: 10px; flex-wrap: nowrap; min-width: 0; }
26665 @media (max-width: 1400px) { .nav-status { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
26666 @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; } }
26667 .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; }
26668 .theme-toggle { width: 38px; justify-content: center; padding: 0; cursor: pointer; transition: transform 0.15s ease, background 0.15s ease; }
26669 .theme-toggle:hover { transform: translateY(-1px); background: rgba(255,255,255,0.16); }
26670 .theme-toggle svg { width: 18px; height: 18px; stroke: currentColor; fill: none; stroke-width: 1.8; }
26671 .theme-toggle .icon-sun { display:none; }
26672 body.dark-theme .theme-toggle .icon-sun { display:block; }
26673 body.dark-theme .theme-toggle .icon-moon { display:none; }
26674 .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;}
26675 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
26676 .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);}
26677 .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;}
26678 .settings-close:hover{color:var(--text);background:var(--surface-2);}
26679 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
26680 .settings-modal-body{padding:14px 16px 16px;}
26681 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
26682 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
26683 .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;}
26684 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
26685 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
26686 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
26687 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
26688 .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;}
26689 .tz-select:focus{border-color:var(--oxide);}
26690 .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; }
26691 .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;}
26692 .page { width: 100%; max-width: 1720px; margin: 0 auto; padding: 32px 24px 36px; }
26693 .hero, .panel, .metric, .path-item { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
26694 .hero, .panel { padding: 22px; }
26695 .hero { margin-bottom: 18px; background: linear-gradient(180deg, rgba(255,255,255,0.30), transparent), var(--surface); }
26696 .hero-top { display:flex; justify-content:space-between; align-items:flex-start; gap:18px; }
26697 .hero-title { margin:0; font-size: 26px; font-weight: 850; letter-spacing: -0.03em; }
26698 .hero-subtitle { margin: 10px 0 0; color: var(--muted); font-size: 16px; line-height: 1.65; }
26699 .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; }
26700 .compare-banner-body { display:flex; flex-direction:column; gap: 10px; }
26701 .compare-banner-top { display:flex; align-items:center; gap: 14px; flex-wrap:wrap; }
26702 .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; }
26703 .compare-banner-actions-left { display:flex; gap:8px; flex-wrap:wrap; }
26704 .compare-banner-meta { display:flex; flex-direction:column; gap:2px; min-width:0; flex: 0 0 auto; }
26705 .delta-chip { font-size:12px; font-weight:700; padding:2px 8px; border-radius:999px; }
26706 .delta-chip.pos { background:var(--pos-bg); color:var(--pos); }
26707 .delta-chip.neg { background:var(--neg-bg); color:var(--neg); }
26708 .delta-cards-inline { display:grid; grid-template-columns:repeat(7,1fr); gap:8px; flex:1 1 auto; }
26709 .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); }
26710 .delta-card-inline:hover { transform:translateY(-3px); box-shadow:0 8px 20px rgba(77,44,20,0.18); z-index:10; }
26711 .delta-card-val { font-size:16px; font-weight:800; }
26712 .delta-card-val.pos { color:#1e7e34; }
26713 .delta-card-val.neg { color:var(--neg); }
26714 .delta-card-val.mod { color:#b35428; }
26715 .delta-card-lbl { font-size:10px; color:var(--muted); margin-top:2px; }
26716 .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; }
26717 .delta-card-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
26718 .delta-card-inline:hover .delta-card-tip { opacity:1; transform:translateX(-50%) translateY(0); }
26719 .compare-label { font-size:11px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; color:var(--info-text, #4467d8); }
26720 .compare-ts { font-size:13px; color:var(--muted); }
26721 .compare-banner-stats { display:flex; align-items:center; gap:10px; font-size:14px; flex-wrap:wrap; }
26722 .compare-arrow { color: var(--muted); }
26723 .action-grid { display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin-top: 18px; }
26724 .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; }
26725 .action-card h3 { margin:0 0 10px; font-size: 16px; text-align:center; }
26726 .action-buttons { display:flex; flex-wrap:wrap; gap: 10px; justify-content:center; }
26727 .run-mgmt-strip { display:flex; flex-wrap:wrap; gap:14px; align-items:stretch; margin-top:18px; }
26728 .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; }
26729 .run-mgmt-card h3 { margin:0 0 4px; font-size:14px; font-weight:800; }
26730 .run-mgmt-card .action-buttons { justify-content:center; }
26731 .run-mgmt-card .action-empty-note { font-size:11px; color:var(--muted); margin:0; text-align:center; }
26732 body.dark-theme .run-mgmt-card { background:var(--surface-2); border-color:var(--line); }
26733 .button, .copy-button {
26734 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;
26735 }
26736 .button.secondary, .copy-button.secondary { background: var(--surface-3); box-shadow: none; color: var(--text); border-color: var(--line-strong); }
26737 @keyframes spin { to { transform: rotate(360deg); } }
26738 .path-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
26739 .path-item { padding: 14px 16px; background: var(--surface-2); display: flex; flex-direction: column; justify-content: center; gap: 4px; }
26740 .path-item-label { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 4px; }
26741 .path-item strong { display: block; margin-bottom: 6px; }
26742 .path-meta { font-size: 12px; color: var(--muted); margin-top: 3px; }
26743 .path-item-split { display: flex; flex-direction: column; justify-content: flex-start; gap: 0; }
26744 .path-subitem { flex: 1; }
26745 .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); }
26746 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); }
26747 .two-col { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 18px; align-items: start; }
26748 table { width: 100%; border-collapse: collapse; font-size: 14px; table-layout: fixed; }
26749 th, td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--line); }
26750 .metrics-table th:first-child, .metrics-table td:first-child { width: 28%; }
26751 th { color: var(--muted); font-weight: 700; }
26752 tr:last-child td { border-bottom: none; }
26753 #subm-tbl col:nth-child(1){width:15%;}
26754 #subm-tbl col:nth-child(2){width:31%;}
26755 #subm-tbl col:nth-child(3){width:9%;}
26756 #subm-tbl col:nth-child(4){width:9%;}
26757 #subm-tbl col:nth-child(5){width:9%;}
26758 #subm-tbl col:nth-child(6){width:9%;}
26759 #subm-tbl col:nth-child(7){width:9%;}
26760 #subm-tbl col:nth-child(8){width:9%;}
26761 .preview-shell { border-radius: 20px; overflow: hidden; border: 1px solid var(--line); background: var(--surface-2); }
26762 iframe { width: 100%; min-height: 1000px; border: none; background: white; }
26763 .empty-preview { padding: 26px; color: var(--muted); line-height: 1.6; }
26764 .pill-row { display:flex; gap:8px; flex-wrap:wrap; }
26765 .hero-quick-actions { display:flex; gap:8px; flex-wrap:nowrap; align-items:center; }
26766 .hero-quick-actions .copy-button, .hero-quick-actions .open-path-btn { font-size:12px; padding:8px 12px; white-space:nowrap; }
26767 .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; }
26768 .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; }
26769 .soft-chip.success svg { flex:0 0 auto; opacity:0.75; }
26770 body.dark-theme .soft-chip.success { background:rgba(143,226,168,0.07); border-color:rgba(143,226,168,0.18); }
26771 .toolbar-row { display:flex; justify-content:space-between; align-items:flex-start; gap: 12px; margin-bottom: 12px; }
26772 .muted { color: var(--muted); }
26773 /* Run-ID chip row (mirrors HTML report) */
26774 .run-id-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-top:14px; }
26775 @media(max-width:960px) { .run-id-row { grid-template-columns:1fr 1fr; } }
26776 @media(max-width:560px) { .run-id-row { grid-template-columns:1fr; } }
26777 .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; }
26778 .run-id-chip[data-copy] { cursor:pointer; }
26779 a.run-id-chip { text-decoration:none; cursor:pointer; }
26780 .run-id-chip:hover { transform:translateY(-3px); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:10; }
26781 .run-id-chip.muted-chip { border-left-color:var(--line-strong); }
26782 .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; }
26783 .run-id-chip.muted-chip .run-id-chip-label { color:var(--muted-2); }
26784 .run-id-chip-value { font-family:ui-monospace,monospace; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
26785 .author-handle { font-size:11px; font-weight:600; color:var(--muted-2); margin-left:1.5em; font-family:ui-monospace,monospace; }
26786 .run-id-chip.muted-chip .run-id-chip-value { color:var(--muted); font-style:italic; }
26787 a.commit-link-value { color:inherit; text-decoration:none; }
26788 a.commit-link-value:hover { color:var(--accent); text-decoration:underline; }
26789 .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; }
26790 .chip-tooltip::before { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
26791 .run-id-chip:hover .chip-tooltip { opacity:1; transform:translateX(-50%) translateY(0); }
26792 .chip-label-icon { display:inline-block; vertical-align:middle; opacity:0.8; flex:0 0 auto; }
26793 .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; }
26794 body.dark-theme .run-id-short-badge { color:var(--muted-2); }
26795 @keyframes chip-flash { 0%{background:var(--accent);color:#fff;} 80%{background:var(--accent);color:#fff;} 100%{background:var(--surface-2);color:var(--text);} }
26796 .chip-copied-flash { animation:chip-flash 0.9s ease forwards; }
26797 /* Meta chips row */
26798 .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%; }
26799 .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; }
26800 .meta-chip:last-child { border-right:none; }
26801 .meta-chip b { color:var(--text); font-weight:700; }
26802 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
26803 .site-footer a{color:var(--muted);}
26804 .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; }
26805 .open-path-btn:hover { border-color: var(--accent); color: var(--accent-2); }
26806 .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; }
26807 .action-empty-note { margin: 6px 0 0; font-size: 12px; color: var(--muted); line-height: 1.4; }
26808 /* Stat chips (matches HTML report) */
26809 .summary-strip { display:grid; grid-template-columns:repeat(8,1fr); gap:10px; margin-top:18px; }
26810 @media(max-width:640px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
26811 /* Hero stat strip: uniform grid where every card is the same width and the
26812 columns line up across both rows. JS sets the column count to ceil(n/2) so
26813 the cards always occupy exactly two rows; when the count is odd the last
26814 card spans two columns to fill the trailing cell with no empty gap. */
26815 .summary-strip-hero { align-items:stretch; }
26816 .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; }
26817 .stat-chip:hover { transform:translateY(-4px); box-shadow:0 12px 32px rgba(77,44,20,0.2); z-index:10; }
26818 .stat-chip-label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); margin-bottom:6px; }
26819 .stat-chip-val { font-size:20px; font-weight:900; color:var(--oxide); }
26820 .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; }
26821 .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); }
26822 .stat-chip-tip::after { content:''; position:absolute; bottom:100%; left:50%; transform:translateX(-50%); border:5px solid transparent; border-bottom-color:var(--text); }
26823 .stat-chip:hover .stat-chip-tip { opacity:1; transform:translateX(-50%) translateY(0); }
26824 .cocomo-box { background:var(--surface); border:1px solid var(--line); border-radius:14px; padding:20px 22px; }
26825 /* COCOMO / Tests strips carry only four chips — pin them to four columns so they fill the
26826 box width instead of inheriting the eight-column hero grid and bunching to the left. */
26827 .cocomo-box .summary-strip { grid-template-columns:repeat(4,1fr); margin-top:0; }
26828 @media(max-width:640px){.cocomo-box .summary-strip{grid-template-columns:repeat(2,1fr);}}
26829 .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; }
26830 .cocomo-box-title { font-size:18px; font-weight:750; color:var(--text); letter-spacing:-0.01em; }
26831 .cocomo-mode-pill-wrap { position:relative; display:inline-flex; align-items:center; cursor:help; }
26832 .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); }
26833 .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); }
26834 .cocomo-mode-tip::before { content:''; position:absolute; bottom:100%; left:14px; border:5px solid transparent; border-bottom-color:var(--text); }
26835 .cocomo-mode-pill-wrap:hover .cocomo-mode-tip { opacity:1; transform:translateY(0); }
26836 .cocomo-box-note { font-size:13px; color:var(--muted); margin-top:10px; line-height:1.6; }
26837 /* Code Ownership panel (contributor table + Combine-contributors merge UI) */
26838 .own-result-box .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);}
26839 .own-result-count{font-size:12px;font-weight:700;color:var(--muted);padding:3px 10px;border-radius:999px;background:var(--surface-3);border:1px solid var(--line-strong);}
26840 .own-result-intro{margin-top:2px;margin-bottom:14px;}
26841 .own-result-scroll{overflow-x:auto;border:1px solid var(--line);border-radius:12px;}
26842 .own-result-table{width:100%;border-collapse:collapse;font-size:13px;}
26843 .own-result-table th{background:var(--surface-3);padding:9px 12px;font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);text-align:left;border-bottom:1px solid var(--line);white-space:nowrap;}
26844 .own-result-table td{padding:8px 12px;border-bottom:1px solid var(--line);vertical-align:middle;}
26845 .own-result-table tr:last-child td{border-bottom:none;}
26846 .own-result-table tbody tr:hover td{background:var(--surface-2);}
26847 .own-result-table .num{text-align:right;font-variant-numeric:tabular-nums;}
26848 .own-result-table .own-pct{font-weight:700;color:var(--oxide);}
26849 .own-dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:8px;vertical-align:middle;}
26850 .own-email{color:var(--muted);font-size:12px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;}
26851 .own-result-box .author-link{color:var(--oxide-2);text-decoration:none;font-weight:inherit;} .own-result-box .author-link:hover{text-decoration:underline;}
26852 .own-result-box .section-header:first-of-type{border-top:none;padding-top:0;margin-top:20px;}
26853 .own-result-box .panel{margin-bottom:0;}
26854 .merge-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(325px,1fr));gap:11px;margin-bottom:14px;}
26855 .merge-opt{display:flex;align-items:center;gap:11px;padding:13px 17px;border:1px solid var(--line);border-radius:12px;background:var(--surface-2);cursor:pointer;transition:transform .22s cubic-bezier(.16,1,.3,1),border-color .18s ease,background .18s ease,box-shadow .22s ease;}
26856 .merge-opt:hover{border-color:var(--oxide);background:var(--surface);transform:translateY(-3px) scale(1.02);box-shadow:0 12px 28px rgba(77,44,20,0.18);}
26857 .merge-opt:hover .merge-opt-dot{transform:scale(1.35);box-shadow:0 0 0 4px rgba(196,92,16,0.15);}
26858 .merge-opt:hover .merge-opt-name{color:var(--oxide-2);}
26859 .merge-opt:active{transform:translateY(-1px) scale(1.0);}
26860 .merge-opt input{accent-color:var(--oxide);width:17px;height:17px;flex:0 0 auto;cursor:pointer;}
26861 .merge-opt-dot{width:11px;height:11px;border-radius:50%;flex:0 0 auto;transition:transform .2s ease,box-shadow .2s ease;}
26862 .merge-opt-name{font-size:15px;font-weight:700;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
26863 .merge-opt-email{font-size:12px;color:var(--muted);margin-left:auto;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px;}
26864 .merge-controls{display:flex;flex-wrap:wrap;align-items:center;gap:10px;}
26865 .merge-name-input{flex:1 1 240px;padding:9px 12px;border:1px solid var(--line);border-radius:10px;background:var(--surface-2);color:var(--text);font-size:13px;outline:none;}
26866 .merge-name-input:focus{border-color:var(--oxide);}
26867 .merge-btn{padding:9px 18px;border:none;border-radius:10px;background:var(--oxide);color:#fff;font-size:13px;font-weight:800;cursor:pointer;transition:background .15s ease,transform .15s ease;}
26868 .merge-btn:hover{background:var(--oxide-2);transform:translateY(-1px);}
26869 .merge-mailmap-link{font-size:12px;font-weight:700;color:var(--oxide-2);text-decoration:none;}
26870 .merge-mailmap-link:hover{text-decoration:underline;}
26871 .merge-existing{margin-top:16px;border-top:1px solid var(--line);padding-top:12px;}
26872 .merge-existing-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-bottom:8px;}
26873 .merge-chip{display:flex;align-items:center;gap:12px;padding:8px 12px;border:1px solid var(--line);border-radius:10px;background:var(--surface-2);margin-bottom:6px;}
26874 .merge-chip-text{font-size:12px;color:var(--muted);flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
26875 .merge-chip-text strong{color:var(--text);font-size:13px;}
26876 .merge-unmerge{padding:5px 12px;border:1px solid var(--line-strong);border-radius:8px;background:var(--surface);color:var(--text);font-size:12px;font-weight:700;cursor:pointer;}
26877 .merge-unmerge:hover{background:var(--surface-2);border-color:var(--oxide);color:var(--oxide-2);}
26878 .merge-mailmap-note{margin:12px 0 0;font-size:12px;line-height:1.6;color:var(--muted);background:var(--surface-2);border:1px solid var(--line);border-left:3px solid var(--oxide);border-radius:8px;padding:10px 13px;}
26879 .merge-mailmap-note strong{color:var(--text);}
26880 /* Submodule panel */
26881 .submodule-panel { margin-top: 18px; margin-bottom: 18px; padding: 18px; border-radius: 16px; border: 1px solid var(--line); background: var(--surface-2); }
26882 /* Metrics tables stack */
26883 .metrics-tables-stack { display: grid; gap: 12px; margin-top: 18px; }
26884 .metrics-tables-lower { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
26885 @media(max-width:640px) { .metrics-tables-lower { grid-template-columns: 1fr; } }
26886 .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)); }
26887 .metrics-table-subtitle { font-size: 10px; font-weight: 600; text-transform: none; letter-spacing: 0; color: var(--muted); margin-left: 4px; }
26888 /* Metrics table */
26889 .metrics-table-wrap { border-radius: 16px; border: 1px solid var(--line); overflow: hidden; background: var(--surface); }
26890 .metrics-table { width: 100%; border-collapse: collapse; font-size: 14px; }
26891 .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; }
26892 .metrics-table thead th:not(:first-child) { text-align: right; }
26893 .metrics-table tbody td { padding: 11px 16px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: middle; }
26894 .metrics-table tbody tr:last-child td { border-bottom: none; }
26895 .metrics-table tbody td:not(:first-child) { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
26896 .metrics-table tbody td:first-child { font-weight: 600; color: var(--text); }
26897 .metrics-table tbody tr:hover td { background: var(--surface-2); }
26898 .mt-category { font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted-2); }
26899 .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; }
26900 .metrics-section-header.metrics-section-gap td { padding-top: 30px !important; border-top: 2px solid var(--line) !important; }
26901 .mt-val-large { font-size: 16px; font-weight: 800; color: var(--text); }
26902 .mt-val-pos { color: var(--pos); font-weight: 700; }
26903 .mt-val-neg { color: var(--neg); font-weight: 700; }
26904 .mt-val-zero { color: var(--muted); }
26905 .mt-val-mod { color: var(--oxide-2); }
26906 .mt-val-na { color: var(--muted-2); font-size: 13px; font-style: italic; }
26907 @media (max-width: 1180px) {
26908 .top-nav-inner, .two-col, .action-grid { grid-template-columns: 1fr; }
26909 .nav-project-slot, .nav-status { justify-content:flex-start; }
26910 .hero-top { flex-direction: column; }
26911 .run-mgmt-strip { flex-direction: column; }
26912 }
26913 .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:13px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
26914 @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));}}
26915 .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;}
26916 /* ── Result-page chart controls ─────────────────────────────────────────── */
26917 .r-chart-section{margin-bottom:24px;}
26918 .section-pair{display:flex;flex-direction:column;gap:24px;width:100%;margin-top:24px;}
26919 .section-pair > .panel{flex-shrink:0;}
26920 .r-chart-controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px;}
26921 .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;}
26922 .r-chart-select:focus{border-color:var(--accent);}
26923 .r-chart-container{width:100%;overflow:hidden;position:relative;flex:1;}
26924 .r-chart-container svg{display:block;width:100%;height:auto;}
26925 .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;}
26926 .r-expand-btn:hover{background:var(--surface);color:var(--text);}
26927 .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;}
26928 .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);}
26929 .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;}
26930 .r-chart-modal-subtitle{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 12px;display:block;letter-spacing:.02em;}
26931 .r-modal-header{display:flex;align-items:center;gap:12px;flex-wrap:nowrap;margin:0 0 16px;padding-right:44px;}
26932 .r-modal-header .r-chart-modal-title{flex:1 1 auto;margin:0;min-width:0;}
26933 .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;}
26934 .r-chart-modal-close:hover{opacity:.7;}
26935 body.dark-theme .r-chart-modal{background:var(--surface);}
26936 .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;}
26937 .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);}
26938 .lang-bar-row{cursor:pointer;transition:transform .2s cubic-bezier(.34,1.56,.64,1);}
26939 .lang-bar-row:hover{transform:translateY(-2px);}
26940 .lang-bar-row .rchit:hover{filter:none;transform:none;}
26941 .lang-bar-row:hover .rchit{filter:brightness(1.12);transform:scaleY(1.22);}
26942 .r-chart-tab-bar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap;}
26943 .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;}
26944 .r-chart-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
26945 .r-chart-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start;}
26946 @media(max-width:720px){.r-chart-grid-2{grid-template-columns:1fr;}}
26947 @media print{.r-chart-controls,.r-chart-tab-bar{display:none!important;}}
26948 #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;}
26949 .r-lang-overview{display:flex;gap:40px;align-items:center;justify-content:center;flex-wrap:wrap;padding:8px 0 16px;}
26950 .r-lang-overview-cell{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1 1 280px;max-width:480px;}
26951 .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;}
26952 .r-viz-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:stretch;}
26953 @media(max-width:820px){.r-viz-grid{grid-template-columns:1fr;}}
26954 .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;}
26955 .r-viz-card-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;color:var(--muted-2);margin:0 0 10px;}
26956 .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;}
26957 .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;}
26958 body.has-report-banner .top-nav{top:27px;}
26959 body.has-report-banner{padding-bottom:27px;}
26960 </style>
26961</head>
26962<body{% if report_header_footer.is_some() %} class="has-report-banner"{% endif %}>
26963 <div class="background-watermarks" aria-hidden="true">
26964 <img src="/images/logo/logo-text.png" alt="" />
26965 <img src="/images/logo/logo-text.png" alt="" />
26966 <img src="/images/logo/logo-text.png" alt="" />
26967 <img src="/images/logo/logo-text.png" alt="" />
26968 <img src="/images/logo/logo-text.png" alt="" />
26969 <img src="/images/logo/logo-text.png" alt="" />
26970 <img src="/images/logo/logo-text.png" alt="" />
26971 <img src="/images/logo/logo-text.png" alt="" />
26972 <img src="/images/logo/logo-text.png" alt="" />
26973 <img src="/images/logo/logo-text.png" alt="" />
26974 <img src="/images/logo/logo-text.png" alt="" />
26975 <img src="/images/logo/logo-text.png" alt="" />
26976 <img src="/images/logo/logo-text.png" alt="" />
26977 <img src="/images/logo/logo-text.png" alt="" />
26978 </div>
26979 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
26980 {% if let Some(banner) = report_header_footer %}
26981 <div class="report-id-banner" aria-label="Report identification">{{ banner|e }}</div>
26982 {% endif %}
26983 <div class="top-nav">
26984 <div class="top-nav-inner">
26985 <a class="brand" href="/">
26986 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
26987 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">local code analysis - metrics, history and reports</div></div>
26988 </a>
26989 <div class="nav-project-slot">
26990 <div class="nav-project-pill"><span class="nav-project-label">REPORT</span><span class="nav-project-value">{{ report_title }}</span></div>
26991 </div>
26992 <div class="nav-status">
26993 <a class="nav-pill sx-a85c6157" href="/" >Home</a>
26994 <div class="nav-dropdown">
26995 <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>
26996 <div class="nav-dropdown-menu">
26997 <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>
26998 </div>
26999 </div>
27000 <a class="nav-pill sx-a85c6157" href="/compare-scans" >Compare Scans</a>
27001 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
27002 <div class="nav-dropdown">
27003 <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>
27004 <div class="nav-dropdown-menu">
27005 <a href="/code-ownership"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>Code Ownership</a>
27006 <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>
27007 </div>
27008 </div>
27009 <div class="server-status-wrap" id="server-status-wrap">
27010 <div class="nav-pill server-online-pill" id="server-status-pill">
27011 <span class="status-dot" id="status-dot"></span>
27012 <span id="server-status-label">Server</span>
27013 <span class="sx-d60f2ef3" id="server-ping-ms" ></span>
27014 </div>
27015 <div class="server-status-tip">
27016 OxideSLOC is running — accessible on your network.
27017 <span class="sx-238af6bc" id="server-tip-ping" ></span>
27018 </div>
27019 </div>
27020 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
27021 <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>
27022 </button>
27023 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme" title="Toggle theme">
27024 <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>
27025 <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>
27026 </button>
27027 </div>
27028 </div>
27029 </div>
27030
27031 <div class="page">
27032 <section class="hero">
27033 <div class="hero-top">
27034 <div>
27035 <div class="sx-5de06b01" >
27036 <h1 class="hero-title sx-38965f9b" >{{ report_title }}</h1>
27037 <span class="run-id-short-badge" title="Short run ID — matches the ID shown in View Reports">{{ run_id_short }}</span>
27038 <div class="soft-chip success sx-c9fd821d" ><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>
27039 </div>
27040 </div>
27041 <div class="hero-quick-actions">
27042 {% if server_mode %}
27043 <button type="button" class="copy-button secondary sx-c7c70665" disabled title="Output folder is on the server — path is not meaningful for remote users" >Copy output folder</button>
27044 {% else %}
27045 <button type="button" class="copy-button secondary" data-copy-value="{{ output_dir }}">Copy output folder</button>
27046 {% endif %}
27047 <button type="button" class="copy-button secondary" data-copy-value="{{ run_id }}">Copy run ID</button>
27048 {% if !server_mode %}
27049 <button type="button" class="copy-button secondary open-path-btn open-folder-button" data-folder="{{ output_dir }}">Open output folder</button>
27050 {% endif %}
27051 <button class="copy-button secondary" id="download-bundle-btn" type="button">Download all artifacts</button>
27052 <button class="copy-button sx-7e6e6255" id="delete-run-btn" type="button" >Delete this run</button>
27053 </div>
27054 </div>
27055
27056 <!-- Run metadata chips: Run ID · Git Commit · Branch · Last Commit By -->
27057 <div class="run-id-row">
27058 <span class="run-id-chip" data-copy="{{ run_id }}">
27059 <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>
27060 <span class="run-id-chip-value">{{ run_id }}</span>
27061 <span class="chip-tooltip">Unique identifier for this analysis run — click to copy</span>
27062 </span>
27063 {% match git_commit_long %}
27064 {% when Some with (long_sha) %}
27065 {% match git_commit_url %}
27066 {% when Some with (commit_url) %}
27067 <a class="run-id-chip" href="{{ commit_url }}" target="_blank" rel="noopener">
27068 <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 sx-6d255c61" 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" ><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>
27069 <span class="run-id-chip-value">{{ long_sha }}</span>
27070 <span class="chip-tooltip">Open commit on version control — click to navigate</span>
27071 </a>
27072 {% when None %}
27073 <span class="run-id-chip" data-copy="{{ long_sha }}">
27074 <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>
27075 <span class="run-id-chip-value">{{ long_sha }}</span>
27076 <span class="chip-tooltip">Full commit SHA for the scanned state — click to copy</span>
27077 </span>
27078 {% endmatch %}
27079 {% when None %}
27080 <span class="run-id-chip muted-chip">
27081 <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>
27082 <span class="run-id-chip-value">Not detected</span>
27083 <span class="chip-tooltip">No Git commit SHA was found for this scan</span>
27084 </span>
27085 {% endmatch %}
27086 {% match git_branch %}
27087 {% when Some with (branch) %}
27088 {% match git_branch_url %}
27089 {% when Some with (branch_url) %}
27090 <a class="run-id-chip" href="{{ branch_url }}" target="_blank" rel="noopener">
27091 <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 sx-6d255c61" 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" ><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>
27092 <span class="run-id-chip-value">{{ branch }}</span>
27093 <span class="chip-tooltip">Open branch on version control — click to navigate</span>
27094 </a>
27095 {% when None %}
27096 <span class="run-id-chip">
27097 <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>
27098 <span class="run-id-chip-value">{{ branch }}</span>
27099 <span class="chip-tooltip">Git branch active at scan time</span>
27100 </span>
27101 {% endmatch %}
27102 {% when None %}
27103 <span class="run-id-chip muted-chip">
27104 <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>
27105 <span class="run-id-chip-value">Not detected</span>
27106 <span class="chip-tooltip">No Git branch was found for this scan</span>
27107 </span>
27108 {% endmatch %}
27109 {% match git_author %}
27110 {% when Some with (author) %}
27111 <span class="run-id-chip" data-author="{{ author }}">
27112 <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>
27113 <span class="run-id-chip-value">{{ author }}<span class="author-handle"></span></span>
27114 <span class="chip-tooltip">Author of the most recent commit at scan time</span>
27115 </span>
27116 {% when None %}
27117 <span class="run-id-chip muted-chip">
27118 <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>
27119 <span class="run-id-chip-value">Not detected</span>
27120 <span class="chip-tooltip">No commit author was found for this scan</span>
27121 </span>
27122 {% endmatch %}
27123 </div>
27124
27125 <!-- Scan metadata row -->
27126 <div class="meta">
27127 <span class="meta-chip">Scan by <b>{{ scan_performed_by }}</b></span>
27128 <span class="meta-chip">Scanned <b class="ts-local" data-utc-ms="{{ scan_time_utc_ms }}">{{ scan_time_display }}</b></span>
27129 <span class="meta-chip">OS <b>{{ os_display }}</b></span>
27130 <span class="meta-chip">Files analyzed <b>{{ files_analyzed|commas }}</b></span>
27131 <span class="meta-chip">Files skipped <b>{{ files_skipped|commas }}</b></span>
27132 </div>
27133
27134 <!-- All summary stat chips in one unified strip (8 columns) -->
27135 <div class="summary-strip summary-strip-hero">
27136 <div class="stat-chip" data-raw="{{ physical_lines }}">
27137 <div class="stat-chip-label">Physical lines</div>
27138 <div class="stat-chip-val">{{ physical_lines }}</div>
27139 <div class="stat-chip-exact"></div>
27140 <div class="stat-chip-tip">Total lines across all analyzed files, including code, comments, and blank lines.</div>
27141 </div>
27142 <div class="stat-chip" data-raw="{{ code_lines }}">
27143 <div class="stat-chip-label">Code</div>
27144 <div class="stat-chip-val">{{ code_lines }}</div>
27145 <div class="stat-chip-exact"></div>
27146 <div class="stat-chip-tip">Lines containing executable source code, excluding comments and blanks.</div>
27147 </div>
27148 <div class="stat-chip" data-raw="{{ comment_lines }}">
27149 <div class="stat-chip-label">Comments</div>
27150 <div class="stat-chip-val">{{ comment_lines }}</div>
27151 <div class="stat-chip-exact"></div>
27152 <div class="stat-chip-tip">Lines consisting entirely of comments or inline documentation.</div>
27153 </div>
27154 <div class="stat-chip" data-raw="{{ blank_lines }}">
27155 <div class="stat-chip-label">Blank</div>
27156 <div class="stat-chip-val">{{ blank_lines }}</div>
27157 <div class="stat-chip-exact"></div>
27158 <div class="stat-chip-tip">Empty or whitespace-only lines used for readability and spacing.</div>
27159 </div>
27160 <div class="stat-chip" data-raw="{{ mixed_lines }}">
27161 <div class="stat-chip-label">Mixed separate</div>
27162 <div class="stat-chip-val">{{ mixed_lines }}</div>
27163 <div class="stat-chip-exact"></div>
27164 <div class="stat-chip-tip">Lines that contain both code and a trailing comment, counted separately per the mixed-line policy.</div>
27165 </div>
27166 <div class="stat-chip" data-raw="{{ functions }}">
27167 <div class="stat-chip-label">Functions</div>
27168 <div class="stat-chip-val">{{ functions }}</div>
27169 <div class="stat-chip-exact"></div>
27170 <div class="stat-chip-tip">Best-effort count of function/method definitions detected across all source files.</div>
27171 </div>
27172 <div class="stat-chip" data-raw="{{ classes }}">
27173 <div class="stat-chip-label">Classes / Types</div>
27174 <div class="stat-chip-val">{{ classes }}</div>
27175 <div class="stat-chip-exact"></div>
27176 <div class="stat-chip-tip">Best-effort count of class, struct, interface, and type definitions.</div>
27177 </div>
27178 <div class="stat-chip" data-raw="{{ variables }}">
27179 <div class="stat-chip-label">Variables</div>
27180 <div class="stat-chip-val">{{ variables }}</div>
27181 <div class="stat-chip-exact"></div>
27182 <div class="stat-chip-tip">Best-effort count of variable and constant declarations.</div>
27183 </div>
27184 <div class="stat-chip" data-raw="{{ imports }}">
27185 <div class="stat-chip-label">Imports</div>
27186 <div class="stat-chip-val">{{ imports }}</div>
27187 <div class="stat-chip-exact"></div>
27188 <div class="stat-chip-tip">Best-effort count of import, include, and module-use statements.</div>
27189 </div>
27190 <div class="stat-chip" data-raw="{{ test_count }}">
27191 <div class="stat-chip-label">Tests</div>
27192 <div class="stat-chip-val">{{ test_count }}</div>
27193 <div class="stat-chip-exact"></div>
27194 <div class="stat-chip-tip">Best-effort count of test cases detected by framework pattern (GTest, PyTest, JUnit, etc.).</div>
27195 </div>
27196 <div class="stat-chip" data-density data-code="{{ code_lines }}" data-physical="{{ physical_lines }}">
27197 <div class="stat-chip-label">Code density</div>
27198 <div class="stat-chip-val stat-chip-density-val">—</div>
27199 <div class="stat-chip-exact"></div>
27200 <div class="stat-chip-tip">Percentage of physical lines that contain executable source code — higher means a leaner, code-dense codebase.</div>
27201 </div>
27202 <div class="stat-chip" data-raw="{{ files_analyzed }}">
27203 <div class="stat-chip-label">Files analyzed</div>
27204 <div class="stat-chip-val">{{ files_analyzed }}</div>
27205 <div class="stat-chip-exact"></div>
27206 <div class="stat-chip-tip">Total number of source files included in this analysis.</div>
27207 </div>
27208 {% if cyclomatic_complexity > 0 %}
27209 <div class="stat-chip" data-raw="{{ cyclomatic_complexity }}" {% if complexity_alert > 0 && cyclomatic_complexity > complexity_alert as u64 %}data-sx-style="border-color:var(--oxide-2);"{% endif %}>
27210 <div class="stat-chip-label">Complexity score</div>
27211 <div class="stat-chip-val">{{ cyclomatic_complexity }}</div>
27212 <div class="stat-chip-exact"></div>
27213 <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>
27214 </div>
27215 {% endif %}
27216 {% if let Some(ls) = lsloc %}
27217 <div class="stat-chip" data-raw="{{ ls }}">
27218 <div class="stat-chip-label">Logical SLOC</div>
27219 <div class="stat-chip-val">{{ ls }}</div>
27220 <div class="stat-chip-exact"></div>
27221 <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>
27222 </div>
27223 {% endif %}
27224 {% if uloc > 0 %}
27225 <div class="stat-chip" data-raw="{{ uloc }}">
27226 <div class="stat-chip-label">Unique SLOC (ULOC)</div>
27227 <div class="stat-chip-val">{{ uloc }}</div>
27228 <div class="stat-chip-exact"></div>
27229 <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>
27230 </div>
27231 {% endif %}
27232 {% if uloc > 0 && dryness_pct_str != "" %}
27233 <div class="stat-chip">
27234 <div class="stat-chip-label">DRYness</div>
27235 <div class="stat-chip-val">{{ dryness_pct_str }}%</div>
27236 <div class="stat-chip-exact"></div>
27237 <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>
27238 </div>
27239 {% endif %}
27240 {% if duplicate_group_count > 0 %}
27241 <div class="stat-chip sx-30e8d2a1" data-raw="{{ duplicate_group_count }}" >
27242 <div class="stat-chip-label">Duplicate groups</div>
27243 <div class="stat-chip-val">{{ duplicate_group_count }}</div>
27244 <div class="stat-chip-exact"></div>
27245 <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>
27246 </div>
27247 {% endif %}
27248 <!-- Reserve "pad" card: revealed by JS only when the visible card count is
27249 odd, so the strip always forms exactly two full rows with every column
27250 aligned and every card the same width (no oversized card, no gap). -->
27251 <div class="stat-chip stat-chip-pad sx-d0466aa3" data-raw="{{ test_assertion_count }}" >
27252 <div class="stat-chip-label">Assertions</div>
27253 <div class="stat-chip-val">{{ test_assertion_count }}</div>
27254 <div class="stat-chip-exact"></div>
27255 <div class="stat-chip-tip">Best-effort count of test assertion call lines (assertEquals, EXPECT_*, etc.) detected across all test files.</div>
27256 </div>
27257 </div>
27258
27259 {% if let Some(prev_id) = prev_run_id %}{% if let Some(prev_ts) = prev_run_timestamp %}
27260 <div class="compare-banner">
27261 <div class="compare-banner-body">
27262 <div class="compare-banner-top">
27263 <div class="compare-banner-meta">
27264 <span class="compare-label">Previous scan</span>
27265 <span class="compare-ts">{{ prev_ts }}</span>
27266 {% if prev_scan_count > 1 %}<span class="compare-ts">{{ prev_scan_count }} scans total</span>{% endif %}
27267 {% if let Some(prev_code) = prev_run_code_lines %}
27268 <div class="compare-banner-stats sx-36e81f86" >
27269 <span>Code before: <strong data-raw="{{ prev_code }}">{{ prev_code }}</strong></span>
27270 <span class="compare-arrow">→</span>
27271 <span>Code now: <strong data-raw="{{ code_lines }}">{{ code_lines }}</strong></span>
27272 {% if let Some(added) = delta_lines_added %}<span class="delta-chip pos">+<span data-raw="{{ added }}">{{ added }}</span> added</span>{% endif %}
27273 {% if let Some(removed) = delta_lines_removed %}<span class="delta-chip neg">−<span data-raw="{{ removed }}">{{ removed }}</span> removed</span>{% endif %}
27274 </div>
27275 {% endif %}
27276 </div>
27277 {% if delta_lines_added.is_some() %}
27278 <div class="delta-cards-inline">
27279 <div class="delta-card-inline">
27280 <div class="delta-card-val pos">{% if let Some(v) = delta_lines_added %}+{{ v|commas }}{% else %}—{% endif %}</div>
27281 <div class="delta-card-lbl">lines added</div>
27282 <div class="delta-card-tip">Code lines added since the previous scan</div>
27283 </div>
27284 <div class="delta-card-inline">
27285 <div class="delta-card-val neg">{% if let Some(v) = delta_lines_removed %}−{{ v|commas }}{% else %}—{% endif %}</div>
27286 <div class="delta-card-lbl">lines removed</div>
27287 <div class="delta-card-tip">Code lines removed since the previous scan</div>
27288 </div>
27289 <div class="delta-card-inline">
27290 <div class="delta-card-val">{% if let Some(v) = delta_unmodified_lines %}{{ v|commas }}{% else %}—{% endif %}</div>
27291 <div class="delta-card-lbl">unmodified lines</div>
27292 <div class="delta-card-tip">Code lines unchanged since the previous scan</div>
27293 </div>
27294 <div class="delta-card-inline">
27295 <div class="delta-card-val mod">{% if let Some(v) = delta_files_modified %}{{ v|commas }}{% else %}—{% endif %}</div>
27296 <div class="delta-card-lbl">files modified</div>
27297 <div class="delta-card-tip">Files with at least one line changed</div>
27298 </div>
27299 <div class="delta-card-inline">
27300 <div class="delta-card-val pos">{% if let Some(v) = delta_files_added %}{{ v|commas }}{% else %}—{% endif %}</div>
27301 <div class="delta-card-lbl">files added</div>
27302 <div class="delta-card-tip">New files added since the previous scan</div>
27303 </div>
27304 <div class="delta-card-inline">
27305 <div class="delta-card-val neg">{% if let Some(v) = delta_files_removed %}{{ v|commas }}{% else %}—{% endif %}</div>
27306 <div class="delta-card-lbl">files removed</div>
27307 <div class="delta-card-tip">Files deleted since the previous scan</div>
27308 </div>
27309 <div class="delta-card-inline">
27310 <div class="delta-card-val">{% if let Some(v) = delta_files_unchanged %}{{ v|commas }}{% else %}—{% endif %}</div>
27311 <div class="delta-card-lbl">files unchanged</div>
27312 <div class="delta-card-tip">Files with no changes since the previous scan</div>
27313 </div>
27314 <div class="delta-card-inline">
27315 <div class="delta-card-val">{% if let Some(v) = delta_files_total %}{{ v|commas }}{% else %}—{% endif %}</div>
27316 <div class="delta-card-lbl">files total</div>
27317 <div class="delta-card-tip">Total files across both scans (modified + added + removed + unchanged)</div>
27318 </div>
27319 </div>
27320 {% else %}
27321 <p class="sx-7c192d5b" >
27322 Line-level delta not available — previous scan's result file could not be read. Re-running will restore full delta tracking.
27323 </p>
27324 {% endif %}
27325 </div>
27326 <div class="compare-banner-actions">
27327 <div class="compare-banner-actions-left">
27328 <a class="button secondary sx-32fb29ef" href="/runs/result/{{ prev_id }}" >View previous report</a>
27329 <a class="button secondary sx-32fb29ef" href="/compare-scans" >Compare scans</a>
27330 </div>
27331 <a class="button sx-32fb29ef" href="/compare?a={{ prev_id }}&b={{ run_id }}" >Full diff →</a>
27332 </div>
27333 </div>
27334 </div>
27335 {% endif %}{% endif %}
27336
27337 <div class="action-grid">
27338 <div class="action-card">
27339 <h3>HTML report</h3>
27340 <div class="action-buttons">
27341 {% match html_url %}
27342 {% when Some with (url) %}
27343 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open HTML</a>
27344 {% when None %}{% endmatch %}
27345 {% match html_download_url %}
27346 {% when Some with (url) %}
27347 <a class="button secondary" href="{{ url }}">Download HTML</a>
27348 {% when None %}{% endmatch %}
27349 {% match html_path %}
27350 {% when Some with (_path) %}{% when None %}{% endmatch %}
27351 <p class="action-empty-note sx-1d436efc" >Interactive report with charts, language breakdown, and per-file detail. Opens in your browser.</p>
27352 </div>
27353 </div>
27354 <div class="action-card">
27355 <h3>PDF report</h3>
27356 <div class="action-buttons">
27357 {% match pdf_url %}
27358 {% when Some with (url) %}
27359 {% if pdf_generating %}
27360 <button class="button sx-aead66b2" id="pdf-open-btn" disabled >
27361 <span class="sx-9f3afbfe" ></span>
27362 Generating PDF…
27363 </button>
27364 {% else %}
27365 <a class="button" href="{{ url }}" target="_blank" rel="noopener" id="pdf-open-btn">Open PDF</a>
27366 {% endif %}
27367 {% when None %}
27368 {% match html_url %}
27369 {% when Some with (_hurl) %}
27370 <a class="button" href="/runs/pdf/{{ run_id }}" target="_blank" rel="noopener" id="pdf-open-btn">Generate PDF</a>
27371 <p class="action-empty-note sx-df83faee" >Generates the PDF report from the scan results. Usually completes within a few seconds.</p>
27372 {% when None %}
27373 <p class="action-empty-note sx-e5c6f3b4" >
27374 PDF could not be generated for this run — Chromium or Edge may not be installed. The HTML report is always available above.
27375 </p>
27376 {% endmatch %}
27377 {% endmatch %}
27378 {% match pdf_download_url %}
27379 {% when Some with (url) %}
27380 <a class="button secondary sx-884cbe59" href="{{ url }}" id="pdf-download-btn"{% if pdf_generating %} {% endif %}>Download PDF</a>
27381 {% when None %}{% endmatch %}
27382 {% match pdf_url %}
27383 {% when Some with (_) %}
27384 <p class="action-empty-note sx-1d436efc" >Print-ready PDF generated from the HTML report. Suitable for sharing or archiving.</p>
27385 {% when None %}{% endmatch %}
27386 </div>
27387 </div>
27388 <div class="action-card">
27389 <h3>JSON result</h3>
27390 <div class="action-buttons">
27391 {% match json_url %}
27392 {% when Some with (url) %}
27393 <a class="button" href="{{ url }}" target="_blank" rel="noopener">Open JSON</a>
27394 {% when None %}{% endmatch %}
27395 {% match json_download_url %}
27396 {% when Some with (url) %}
27397 <a class="button secondary" href="{{ url }}">Download JSON</a>
27398 {% when None %}{% endmatch %}
27399 {% match json_path %}
27400 {% when Some with (_path) %}
27401 <p class="action-empty-note sx-1d436efc" >Machine-readable scan result for CI pipelines, scripting, or re-rendering reports.</p>
27402 {% when None %}
27403 <p class="action-empty-note">JSON not enabled for this run — re-run with JSON artifact enabled to get a machine-readable result.</p>
27404 {% endmatch %}
27405 </div>
27406 </div>
27407 <div class="action-card">
27408 <h3>Scan config</h3>
27409 <div class="action-buttons">
27410 <a class="button secondary" href="{{ scan_config_url }}">Download config</a>
27411 <a class="button sx-08dc943d" href="/scan-setup" >Run another scan</a>
27412 <p class="action-empty-note sx-1d436efc" >Download scan-config.json to replay this exact setup via the Scan Setup page.</p>
27413 </div>
27414 </div>
27415 {% if confluence_configured %}
27416 <div class="action-card" id="confluenceCard">
27417 <h3>Confluence</h3>
27418 <div class="action-buttons">
27419 <button class="button" id="postConfluenceBtn" type="button">Post to Confluence</button>
27420 <button class="button secondary" id="copyWikiBtn" type="button">Copy Wiki Markup</button>
27421 </div>
27422 <p class="action-empty-note sx-1d436efc" >Create or update a Confluence page with this scan result, or copy wiki markup for manual paste.</p>
27423 </div>
27424 {% endif %}
27425 </div>
27426 {% if confluence_configured %}
27427 <div class="sx-75f31a73" id="confluenceModal" >
27428 <div class="sx-3996551e" >
27429 <div class="sx-57b0349b" >Post to Confluence</div>
27430 <label class="sx-806c372e" >Page Title</label>
27431 <input class="sx-1b68562b" id="confPageTitle" type="text" value="OxideSLOC — {{ report_title }}" >
27432 <label class="sx-806c372e" >Report URL <span class="sx-1d258e26" >(optional — linked in page body)</span></label>
27433 <input class="sx-1b68562b" id="confReportUrl" type="url" placeholder="http://127.0.0.1:4317/runs/result/{{ run_id }}" >
27434 <div class="sx-ea0b282d" id="confStatus" ></div>
27435 <div class="sx-49fc5621" >
27436 <button class="button secondary" id="confCancelBtn" type="button">Cancel</button>
27437 <button class="button" id="confSubmitBtn" type="button">Post</button>
27438 </div>
27439 </div>
27440 </div>
27441 {% endif %}
27442 <div class="sx-aff736e5" id="delete-run-modal" >
27443 <div class="sx-93c3c708" >
27444 <div class="sx-97551196" >Delete run — irreversible</div>
27445 <p class="sx-aed4a64d" >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>
27446 <div class="sx-1db0ab8d" id="delete-run-status" ></div>
27447 <div class="sx-f154314f" >
27448 <button class="button secondary sx-6f042b89" id="delete-run-cancel" type="button" >Cancel</button>
27449 <button class="button sx-9ea84a6b" id="delete-run-confirm" type="button" >Yes, delete permanently</button>
27450 </div>
27451 </div>
27452 </div>
27453 {% if !submodule_rows.is_empty() %}
27454 <div class="submodule-panel">
27455 <div class="toolbar-row">
27456 <div>
27457 <h2 class="sx-954b0139" >Submodule breakdown</h2>
27458 <p class="muted sx-38965f9b" >Git submodules detected — each is shown as a separate project slice.</p>
27459 </div>
27460 <div class="pill-row"><span class="soft-chip">{{ submodule_rows.len() }} submodule{% if submodule_rows.len() != 1 %}s{% endif %}</span></div>
27461 </div>
27462 <div class="sx-86e55239" >
27463 <table class="sx-98748fdb" id="subm-tbl" >
27464 <colgroup><col class="sx-295d28ff" ><col class="sx-cffff263" ><col class="sx-2be6e492" ><col class="sx-2be6e492" ><col class="sx-2be6e492" ><col class="sx-2be6e492" ><col class="sx-2be6e492" ><col class="sx-2be6e492" ></colgroup>
27465 <thead>
27466 <tr>
27467 <th class="sx-d1e8d03c" >Submodule</th>
27468 <th class="sx-e4ba4877" >Path</th>
27469 <th class="sx-d2f0b15c" >Files</th>
27470 <th class="sx-d2f0b15c" >Physical</th>
27471 <th class="sx-d2f0b15c" >Code</th>
27472 <th class="sx-d2f0b15c" >Comments</th>
27473 <th class="sx-d2f0b15c" >Blank</th>
27474 <th class="sx-9eb6a01a" >Report</th>
27475 </tr>
27476 </thead>
27477 <tbody>
27478 {% for row in submodule_rows %}
27479 <tr>
27480 <td class="sx-b03d9852" title="{{ row.name }}"><strong>{{ row.name }}</strong></td>
27481 <td class="sx-59770ce0" title="{{ row.relative_path }}"><code class="sx-902e8daa" >{{ row.relative_path }}</code></td>
27482 <td class="sx-a15ec64c" >{{ row.files_analyzed|commas }}</td>
27483 <td class="sx-a15ec64c" >{{ row.total_physical_lines|commas }}</td>
27484 <td class="sx-a15ec64c" >{{ row.code_lines|commas }}</td>
27485 <td class="sx-a15ec64c" >{{ row.comment_lines|commas }}</td>
27486 <td class="sx-a15ec64c" >{{ row.blank_lines|commas }}</td>
27487 <td class="sx-273aef9f" >{% if let Some(url) = row.html_url %}<a class="button sx-66f5341d" href="{{ url }}" target="_blank" rel="noopener" >View</a>{% else %}<span class="sx-3dc23ded" >—</span>{% endif %}</td>
27488 </tr>
27489 {% endfor %}
27490 </tbody>
27491 </table>
27492 </div>
27493 </div>
27494 {% endif %}
27495
27496 <div class="metrics-tables-stack">
27497
27498 <div class="metrics-table-wrap">
27499 <div class="metrics-table-title">Files</div>
27500 <table class="metrics-table">
27501 <thead>
27502 <tr>
27503 <th>Metric</th>
27504 <th>This Run</th>
27505 <th>Previous</th>
27506 <th>Change</th>
27507 </tr>
27508 </thead>
27509 <tbody>
27510 <tr>
27511 <td>Files analyzed</td>
27512 <td class="mt-val-large">{{ files_analyzed|commas }}</td>
27513 <td>{{ prev_fa_str|commas }}</td>
27514 <td><span class="mt-val-{{ delta_fa_class }}">{{ delta_fa_str|commas }}</span></td>
27515 </tr>
27516 <tr>
27517 <td>Files skipped</td>
27518 <td>{{ files_skipped|commas }}</td>
27519 <td>{{ prev_fs_str|commas }}</td>
27520 <td><span class="mt-val-{{ delta_fs_class }}">{{ delta_fs_str|commas }}</span></td>
27521 </tr>
27522 <tr>
27523 <td>Files modified</td>
27524 <td class="mt-val-na">—</td>
27525 <td class="mt-val-na">—</td>
27526 <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>
27527 </tr>
27528 <tr>
27529 <td>Files unchanged</td>
27530 <td class="mt-val-na">—</td>
27531 <td class="mt-val-na">—</td>
27532 <td>{% if let Some(v) = delta_files_unchanged %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
27533 </tr>
27534 <tr>
27535 <td>Files total</td>
27536 <td class="mt-val-na">—</td>
27537 <td class="mt-val-na">—</td>
27538 <td>{% if let Some(v) = delta_files_total %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">—</span>{% endif %}</td>
27539 </tr>
27540 </tbody>
27541 </table>
27542 </div>
27543
27544 <div class="metrics-table-wrap">
27545 <div class="metrics-table-title">Line Counts</div>
27546 <table class="metrics-table">
27547 <thead>
27548 <tr>
27549 <th>Metric</th>
27550 <th>This Run</th>
27551 <th>Previous</th>
27552 <th>Change</th>
27553 </tr>
27554 </thead>
27555 <tbody>
27556 <tr>
27557 <td>Physical lines</td>
27558 <td class="mt-val-large">{{ physical_lines|commas }}</td>
27559 <td>{{ prev_pl_str|commas }}</td>
27560 <td><span class="mt-val-{{ delta_pl_class }}">{{ delta_pl_str|commas }}</span></td>
27561 </tr>
27562 <tr>
27563 <td>Code lines</td>
27564 <td class="mt-val-large">{{ code_lines|commas }}</td>
27565 <td>{{ prev_cl_str|commas }}</td>
27566 <td><span class="mt-val-{{ delta_cl_class }}">{{ delta_cl_str|commas }}</span></td>
27567 </tr>
27568 <tr>
27569 <td>Comment lines</td>
27570 <td>{{ comment_lines|commas }}</td>
27571 <td>{{ prev_cml_str|commas }}</td>
27572 <td><span class="mt-val-{{ delta_cml_class }}">{{ delta_cml_str|commas }}</span></td>
27573 </tr>
27574 <tr>
27575 <td>Blank lines</td>
27576 <td>{{ blank_lines|commas }}</td>
27577 <td>{{ prev_bl_str|commas }}</td>
27578 <td><span class="mt-val-{{ delta_bl_class }}">{{ delta_bl_str|commas }}</span></td>
27579 </tr>
27580 <tr>
27581 <td>Mixed (separate)</td>
27582 <td>{{ mixed_lines|commas }}</td>
27583 <td class="mt-val-na">—</td>
27584 <td class="mt-val-na">—</td>
27585 </tr>
27586 </tbody>
27587 </table>
27588 </div>
27589
27590 <div class="metrics-tables-lower">
27591 <div class="metrics-table-wrap">
27592 <div class="metrics-table-title">Code Structure</div>
27593 <table class="metrics-table">
27594 <thead>
27595 <tr>
27596 <th>Metric</th>
27597 <th>This Run</th>
27598 </tr>
27599 </thead>
27600 <tbody>
27601 <tr>
27602 <td>Functions</td>
27603 <td>{{ functions|commas }}</td>
27604 </tr>
27605 <tr>
27606 <td>Classes / Types</td>
27607 <td>{{ classes|commas }}</td>
27608 </tr>
27609 <tr>
27610 <td>Variables</td>
27611 <td>{{ variables|commas }}</td>
27612 </tr>
27613 <tr>
27614 <td>Imports</td>
27615 <td>{{ imports|commas }}</td>
27616 </tr>
27617 </tbody>
27618 </table>
27619 </div>
27620
27621 <div class="metrics-table-wrap">
27622 <div class="metrics-table-title">Line Change Summary <span class="metrics-table-subtitle">vs previous scan</span></div>
27623 <table class="metrics-table">
27624 <thead>
27625 <tr>
27626 <th>Metric</th>
27627 <th>Change</th>
27628 </tr>
27629 </thead>
27630 <tbody>
27631 <tr>
27632 <td>Lines added</td>
27633 <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>
27634 </tr>
27635 <tr>
27636 <td>Lines removed</td>
27637 <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>
27638 </tr>
27639 <tr>
27640 <td>Lines modified (net)</td>
27641 <td><span class="mt-val-{{ delta_lines_net_class }}">{{ delta_lines_net_str|commas }}</span></td>
27642 </tr>
27643 <tr>
27644 <td>Lines unmodified</td>
27645 <td>{% if let Some(v) = delta_unmodified_lines %}<span>{{ v|commas }}</span>{% else %}<span class="mt-val-na">No prior scan</span>{% endif %}</td>
27646 </tr>
27647 </tbody>
27648 </table>
27649 </div>
27650 </div>
27651
27652 </div>
27653
27654 <div class="path-list">
27655 <div class="path-item">
27656 <div class="path-item-label">Project path</div>
27657 {% if project_path.is_empty() %}<code class="sx-eac76940" 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 %}
27658 </div>
27659 <div class="path-item">
27660 <div class="path-item-label">Git branch</div>
27661 {% if let Some(branch) = git_branch %}
27662 <code>{{ branch }}{% if let Some(sha) = git_commit %} @ {{ sha }}{% endif %}</code>
27663 {% if let Some(author) = git_author %}<div class="path-meta">Last commit by {{ author }}</div>{% endif %}
27664 {% else %}
27665 <code class="sx-eac76940" >—</code>
27666 {% endif %}
27667 </div>
27668 <div class="path-item">
27669 <div class="path-item-label">Output folder</div>
27670 <code class="sx-e765c2e1" >{{ output_dir }}</code>
27671 </div>
27672 <div class="path-item">
27673 <div class="path-item-label">Run ID</div>
27674 <div class="sx-ed46f3ae" >
27675 <code class="sx-1d11d813" >{{ run_id }}</code>
27676 <span class="path-item-scan-badge">scan #{{ current_scan_number }}</span>
27677 </div>
27678 </div>
27679 </div>
27680 </section>
27681
27682 {% if has_cocomo %}
27683 <div class="cocomo-box sx-04bbec5e" >
27684 <div class="cocomo-box-head">
27685 <span class="cocomo-box-title">Constructive Cost Model — COCOMO I</span>
27686 <span class="cocomo-mode-pill-wrap sx-8a450d4e" >
27687 <span class="cocomo-mode-pill">{{ cocomo_mode_label }} mode</span>
27688 <span class="cocomo-mode-tip">{{ cocomo_mode_tooltip }}</span>
27689 </span>
27690 </div>
27691 <div class="summary-strip sx-8fd19811" >
27692 <div class="stat-chip">
27693 <div class="stat-chip-label">Person-months</div>
27694 <div class="stat-chip-val">{{ cocomo_effort_str|commas }}</div>
27695 <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>
27696 </div>
27697 <div class="stat-chip">
27698 <div class="stat-chip-label">Schedule (months)</div>
27699 <div class="stat-chip-val">{{ cocomo_duration_str|commas }}</div>
27700 <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>
27701 </div>
27702 <div class="stat-chip">
27703 <div class="stat-chip-label">Avg. Team Size</div>
27704 <div class="stat-chip-val">{{ cocomo_staff_str|commas }}</div>
27705 <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>
27706 </div>
27707 <div class="stat-chip">
27708 <div class="stat-chip-label">Input KSLOC</div>
27709 <div class="stat-chip-val">{{ cocomo_ksloc_str|commas }}K</div>
27710 <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>
27711 </div>
27712 </div>
27713 <div class="cocomo-box-note sx-32fb29ef" >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>
27714 </div>
27715 {% endif %}
27716
27717 <!-- ── Tests & Coverage brief summary ────────────────────────────────── -->
27718 <div class="cocomo-box sx-04bbec5e" >
27719 <div class="cocomo-box-head">
27720 <span class="cocomo-box-title">Tests & Coverage</span>
27721 {% if has_coverage_data %}
27722 <span class="cocomo-mode-pill-wrap sx-8a450d4e" >
27723 <span class="cocomo-mode-pill sx-c586fb0a" >Coverage data present</span>
27724 </span>
27725 {% endif %}
27726 </div>
27727 <div class="summary-strip sx-8fd19811" >
27728 <div class="stat-chip">
27729 <div class="stat-chip-val" data-fmt="{{ test_count }}">{{ test_count|commas }}</div>
27730 <div class="stat-chip-label">Test Functions</div>
27731 <div class="stat-chip-tip">Lexically detected test case / function definitions</div>
27732 </div>
27733 <div class="stat-chip">
27734 {% if has_coverage_data %}
27735 <div class="stat-chip-val sx-ccd997a1" >{{ cov_line_pct }}%</div>
27736 {% else %}
27737 <div class="stat-chip-val sx-911ada61" >—</div>
27738 {% endif %}
27739 <div class="stat-chip-label">Line Coverage</div>
27740 <div class="stat-chip-tip">Overall line coverage from LCOV / Cobertura / JaCoCo data</div>
27741 </div>
27742 <div class="stat-chip">
27743 {% if !cov_fn_pct.is_empty() %}
27744 <div class="stat-chip-val sx-ccd997a1" >{{ cov_fn_pct }}%</div>
27745 {% else %}
27746 <div class="stat-chip-val sx-911ada61" >—</div>
27747 {% endif %}
27748 <div class="stat-chip-label">Fn Coverage</div>
27749 <div class="stat-chip-tip">Overall function coverage — requires function-level LCOV data</div>
27750 </div>
27751 <div class="stat-chip">
27752 {% if !cov_branch_pct.is_empty() %}
27753 <div class="stat-chip-val sx-ccd997a1" >{{ cov_branch_pct }}%</div>
27754 {% else %}
27755 <div class="stat-chip-val sx-911ada61" >—</div>
27756 {% endif %}
27757 <div class="stat-chip-label">Branch Coverage</div>
27758 <div class="stat-chip-tip">Overall branch coverage — requires branch-level LCOV data</div>
27759 </div>
27760 </div>
27761 {% if has_coverage_data %}
27762 <div class="cocomo-box-note">Lines instrumented: <strong>{{ cov_lines_summary }}</strong> · Open the full HTML report for a per-file breakdown.</div>
27763 {% else %}
27764 <div class="cocomo-box-note">No code coverage detected. Re-run with <code>--lcov-path <coverage.info></code> to populate this section.</div>
27765 {% endif %}
27766 </div>
27767
27768 {{ ownership_html|safe }}
27769
27770 <div class="section-pair">
27771 <section class="panel">
27772 <div class="toolbar-row">
27773 <div>
27774 <h2>Language Breakdown</h2>
27775 <p class="muted">A quick summary of what this run actually counted across supported languages.</p>
27776 </div>
27777 <button class="r-expand-btn" id="result-lang-overview-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
27778 </div>
27779 <div class="sx-bb5bea38" id="result-lang-charts" ></div>
27780 </section>
27781
27782 <section class="panel r-chart-section">
27783 <div class="toolbar-row sx-7564ec60" >
27784 <div>
27785 <h2>Visualizations</h2>
27786 <p class="muted">Interactive charts for this scan — use the controls to switch views.</p>
27787 </div>
27788 </div>
27789
27790 <div class="r-viz-grid">
27791 <div class="r-viz-card">
27792 <div class="sx-3a744011" >
27793 <p class="r-viz-card-title sx-1181d69d" >Language Composition</p>
27794 <button class="r-expand-btn" id="r-composition-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
27795 </div>
27796 <div class="r-chart-tab-bar">
27797 <button class="r-chart-tab active" data-rcomp="abs">Absolute</button>
27798 <button class="r-chart-tab" data-rcomp="pct">100% Normalized</button>
27799 </div>
27800 <div class="r-chart-container" id="r-composition-chart"></div>
27801 </div>
27802 <div class="r-viz-card">
27803 <div class="sx-66b97f4b" >
27804 <p class="r-viz-card-title sx-1181d69d" >Files vs Code Lines</p>
27805 <button class="r-expand-btn" id="r-scatter-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
27806 </div>
27807 <div class="r-chart-container" id="r-scatter-chart"></div>
27808 </div>
27809 {% if has_semantic_data %}
27810 <div class="r-viz-card">
27811 <div class="sx-3a744011" >
27812 <p class="r-viz-card-title sx-1181d69d" >Semantic Metrics</p>
27813 <select class="r-chart-select" id="r-semantic-metric">
27814 <option value="functions">Functions</option>
27815 <option value="classes">Classes</option>
27816 <option value="variables">Variables</option>
27817 <option value="imports">Imports</option>
27818 <option value="tests">Tests</option>
27819 </select>
27820 <button class="r-expand-btn" id="r-semantic-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
27821 </div>
27822 <div class="r-chart-container" id="r-semantic-chart"></div>
27823 </div>
27824 {% endif %}
27825 <div class="r-viz-card">
27826 <div class="sx-66b97f4b" >
27827 <p class="r-viz-card-title sx-1181d69d" >Comment Density</p>
27828 <button class="r-expand-btn" id="r-density-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
27829 </div>
27830 <div class="r-chart-container" id="r-density-chart"></div>
27831 </div>
27832 <div class="r-viz-card">
27833 <div class="sx-66b97f4b" >
27834 <p class="r-viz-card-title sx-1181d69d" >Avg Lines per File</p>
27835 <button class="r-expand-btn" id="r-avglines-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
27836 </div>
27837 <div class="r-chart-container" id="r-avglines-chart"></div>
27838 </div>
27839 <div class="r-viz-card">
27840 <div class="sx-9ef668bf" >
27841 <p class="r-viz-card-title sx-1181d69d" >Repository Overview</p>
27842 <select class="r-chart-select" id="r-sub-metric">
27843 <option value="code">Code Lines</option>
27844 <option value="comment">Comments</option>
27845 <option value="blank">Blank Lines</option>
27846 <option value="physical">Physical Lines</option>
27847 <option value="files">Files</option>
27848 </select>
27849 <select class="r-chart-select" id="r-sub-sort">
27850 <option value="desc">Value ↓</option>
27851 <option value="asc">Value ↑</option>
27852 <option value="name">Name A→Z</option>
27853 </select>
27854 <button class="r-expand-btn" id="r-submodule-expand" title="View full chart" aria-label="Expand chart">⤢ Full View</button>
27855 </div>
27856 <div class="r-chart-container" id="r-submodule-chart"></div>
27857 </div>
27858 </div>
27859
27860 </section>
27861 </div>
27862
27863 </div>
27864
27865 <div id="r-tt" aria-hidden="true"></div>
27866
27867 <script nonce="{{ csp_nonce }}">
27868 (function () {
27869 var body = document.body;
27870 var themeToggle = document.getElementById('theme-toggle');
27871 var storageKey = 'oxide-sloc-theme';
27872
27873 function applyTheme(theme) {
27874 body.classList.toggle('dark-theme', theme === 'dark');
27875 }
27876
27877 function loadSavedTheme() {
27878 try {
27879 var saved = localStorage.getItem(storageKey);
27880 if (saved === 'dark' || saved === 'light') {
27881 applyTheme(saved);
27882 }
27883 } catch (e) {}
27884 }
27885
27886 if (themeToggle) {
27887 themeToggle.addEventListener('click', function () {
27888 var nextTheme = body.classList.contains('dark-theme') ? 'light' : 'dark';
27889 applyTheme(nextTheme);
27890 try { localStorage.setItem(storageKey, nextTheme); } catch (e) {}
27891 });
27892 }
27893
27894 Array.prototype.slice.call(document.querySelectorAll('[data-copy-value]')).forEach(function (button) {
27895 button.addEventListener('click', function () {
27896 var value = button.getAttribute('data-copy-value') || '';
27897 if (!value) return;
27898 var originalText = button.textContent;
27899 function flashSuccess() {
27900 button.textContent = 'Copied!';
27901 setTimeout(function () { button.textContent = originalText; }, 1800);
27902 }
27903 function flashFail() {
27904 button.textContent = 'Copy failed';
27905 setTimeout(function () { button.textContent = originalText; }, 2000);
27906 }
27907 if (navigator.clipboard && navigator.clipboard.writeText) {
27908 navigator.clipboard.writeText(value).then(flashSuccess, function () {
27909 fallbackCopy(value, flashSuccess, flashFail);
27910 });
27911 } else {
27912 fallbackCopy(value, flashSuccess, flashFail);
27913 }
27914 });
27915 });
27916 function fallbackCopy(text, onSuccess, onFail) {
27917 try {
27918 var ta = document.createElement('textarea');
27919 ta.value = text;
27920 ta.style.position = 'fixed';
27921 ta.style.top = '-9999px';
27922 ta.style.left = '-9999px';
27923 document.body.appendChild(ta);
27924 ta.focus();
27925 ta.select();
27926 var ok = document.execCommand('copy');
27927 document.body.removeChild(ta);
27928 if (ok) { onSuccess(); } else { onFail(); }
27929 } catch (e) { onFail(); }
27930 }
27931
27932 Array.prototype.slice.call(document.querySelectorAll('.open-folder-button')).forEach(function (btn) {
27933 btn.addEventListener('click', function () {
27934 var folder = btn.getAttribute('data-folder') || '';
27935 if (!folder) return;
27936 var orig = btn.textContent;
27937 fetch('/open-path?path=' + encodeURIComponent(folder))
27938 .then(function (r) { return r.json(); })
27939 .then(function (d) {
27940 if (d && d.server_mode_disabled) {
27941 window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
27942 } else if (d && d.ok) {
27943 btn.textContent = 'Opened!';
27944 setTimeout(function () { btn.textContent = orig; }, 1800);
27945 }
27946 })
27947 .catch(function () {
27948 btn.textContent = 'Failed';
27949 setTimeout(function () { btn.textContent = orig; }, 2000);
27950 });
27951 });
27952 });
27953
27954 loadSavedTheme();
27955
27956 // ── Compact number formatting for stat chips ──────────────────────────
27957 (function(){
27958 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();}
27959 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-raw]')).forEach(function(chip){
27960 var raw=parseInt(chip.getAttribute('data-raw'),10);
27961 if(isNaN(raw))return;
27962 var valEl=chip.querySelector('.stat-chip-val');
27963 if(valEl)valEl.textContent=fmt(raw);
27964 var exactEl=chip.querySelector('.stat-chip-exact');
27965 if(exactEl)exactEl.textContent=raw>=10000?raw.toLocaleString():'';
27966 });
27967 // Code density chip
27968 Array.prototype.slice.call(document.querySelectorAll('.stat-chip[data-density]')).forEach(function(chip){
27969 var code=parseInt(chip.getAttribute('data-code'),10);
27970 var phys=parseInt(chip.getAttribute('data-physical'),10);
27971 if(isNaN(code)||isNaN(phys)||phys===0)return;
27972 var pct=(code/phys*100).toFixed(1)+'%';
27973 var valEl=chip.querySelector('.stat-chip-val');
27974 if(valEl)valEl.textContent=pct;
27975 });
27976 // Populate author handle from data-author attribute
27977 Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-author]')).forEach(function(chip){
27978 var author=chip.getAttribute('data-author');
27979 var el=chip.querySelector('.author-handle');
27980 if(el)el.textContent='/'+author.replace(/\s+/g,'');
27981 });
27982 // Click-to-copy on run-id-chip elements
27983 Array.prototype.slice.call(document.querySelectorAll('.run-id-chip[data-copy]')).forEach(function(chip){
27984 chip.addEventListener('click',function(){
27985 var val=chip.getAttribute('data-copy');
27986 if(!val)return;
27987 if(navigator.clipboard){navigator.clipboard.writeText(val).catch(function(){});}
27988 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);}
27989 chip.classList.add('chip-copied-flash');
27990 setTimeout(function(){chip.classList.remove('chip-copied-flash');},900);
27991 });
27992 });
27993 // Format delta card values with data-raw using comma-separated full numbers
27994 Array.prototype.slice.call(document.querySelectorAll('.delta-cards-inline .delta-card-inline[data-raw]')).forEach(function(card){
27995 var raw=parseInt(card.getAttribute('data-raw'),10);
27996 if(isNaN(raw))return;
27997 var valEl=card.querySelector('.delta-card-val');
27998 if(valEl)valEl.textContent=raw.toLocaleString();
27999 });
28000 // Format code-before / code-now numbers in the compare banner stats line
28001 Array.prototype.slice.call(document.querySelectorAll('.compare-banner-stats [data-raw]')).forEach(function(el){
28002 var raw=parseInt(el.getAttribute('data-raw'),10);
28003 if(!isNaN(raw))el.textContent=raw.toLocaleString();
28004 });
28005 })();
28006
28007 // ── Shared tooltip for all result-page charts ─────────────────────────
28008 var rTT=(function(){
28009 var el=document.getElementById('r-tt');
28010 if(!el)return{s:function(){},h:function(){},m:function(){}};
28011 function show(e,html){el.innerHTML=html;el.style.display='block';move(e);}
28012 function hide(){el.style.display='none';}
28013 function move(e){
28014 var x=e.clientX+16,y=e.clientY-12;
28015 var r=el.getBoundingClientRect();
28016 if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;
28017 if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;
28018 el.style.left=x+'px';el.style.top=y+'px';
28019 }
28020 return{s:show,h:hide,m:move};
28021 })();
28022 window.rTT=rTT;
28023
28024 // ── Tooltip event delegation (CSP-safe, no inline handlers needed) ────
28025 (function(){
28026 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
28027 document.addEventListener('mouseover',function(e){
28028 var t=e.target;
28029 while(t&&t.getAttribute){
28030 var l=t.getAttribute('data-ttl');
28031 if(l!==null){
28032 var v=t.getAttribute('data-ttv')||'';
28033 rTT.s(e,'<strong>'+escH(l)+'</strong><br>'+escH(v).replace(/\n/g,'<br>'));
28034 return;
28035 }
28036 t=t.parentNode;
28037 }
28038 });
28039 document.addEventListener('mouseout',function(e){
28040 var t=e.target;
28041 while(t&&t.getAttribute){
28042 if(t.getAttribute('data-ttl')!==null){rTT.h();return;}
28043 t=t.parentNode;
28044 }
28045 });
28046 document.addEventListener('mousemove',function(e){
28047 var el=document.getElementById('r-tt');
28048 if(el&&el.style.display!=='none')rTT.m(e);
28049 });
28050 window.addEventListener('blur',function(){rTT.h();});
28051 document.addEventListener('visibilitychange',function(){if(document.hidden)rTT.h();});
28052 })();
28053
28054 // ── Language overview charts ───────────────────────────────────────────
28055 (function(){
28056 var D={{ lang_chart_json|safe }};
28057 if(!D||!D.length)return;
28058 var el=document.getElementById('result-lang-charts');
28059 if(!el)return;
28060 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
28061 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082'];
28062 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
28063 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();}
28064 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
28065 function px(n){return Math.round(n);}
28066 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+'"';}
28067 // Largest font size (<=10) at which `t` fits in a `w`-wide segment, or 0 if
28068 // it cannot fit legibly even at the 6.5 floor. Lets bar labels shrink to fit
28069 // instead of vanishing; the SVG scales up in Full View so small fonts stay legible.
28070 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;}
28071 var tot=D.reduce(function(a,d){return a+d.code;},0)||1;
28072
28073 // Donut chart — height matches the stacked-bar chart so both panels align
28074 var rHb_d=28;
28075 var DH=Math.max(220,D.length*rHb_d+32);
28076 var cx=100,cy=Math.round(DH/2),Ro=88,Ri=48;
28077 var legX=208,DW=395;
28078 var legCount=D.length;
28079 var legSpacing=Math.max(12,Math.min(22,Math.floor((DH-30)/Math.max(legCount,1))));
28080 var legYStart=Math.round((DH-legCount*legSpacing)/2);
28081 var ds='<svg class="sx-b882cc83" id="dnt-svg" viewBox="0 0 '+DW+' '+DH+'" width="'+DW+'" height="'+DH+'" xmlns="http://www.w3.org/2000/svg">';
28082 // One shared transition on every donut element so slices, leader lines,
28083 // outside labels, % labels and the legend all animate together as a single
28084 // picture when a language is hovered. Slices scale from the donut centre.
28085 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>';
28086 if(D.length===1){
28087 var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
28088 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+'"/>';
28089 } else {
28090 var smalls=[];
28091 var ang=-Math.PI/2;
28092 D.forEach(function(d,i){
28093 var sw=Math.min(d.code/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
28094 var x1=cx+Ro*Math.cos(ang),y1=cy+Ro*Math.sin(ang);
28095 var x2=cx+Ro*Math.cos(a2),y2=cy+Ro*Math.sin(a2);
28096 var xi1=cx+Ri*Math.cos(a2),yi1=cy+Ri*Math.sin(a2);
28097 var xi2=cx+Ri*Math.cos(ang),yi2=cy+Ri*Math.sin(ang);
28098 var pct=Math.round(d.code/tot*100);
28099 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"/>';
28100 if(pct>=5){var mAng=ang+sw/2,mR=(Ro+Ri)/2;ds+='<text class="sx-c3270469" 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" >'+pct+'%</text>';}else if(pct>0){smalls.push({mAng:ang+sw/2,pct:pct,lang:d.lang,col:COLS[i%COLS.length]});}
28101 ang+=sw;
28102 });
28103 // Small slices (<5%) get outside labels positioned near each slice's own
28104 // angular position (a slice on the left gets its label/leader on the left),
28105 // then nudged apart horizontally so text never overlaps. Leader lines point
28106 // from each slice to its label. Horizontal text keeps long names legible;
28107 // the whole SVG scales up in Full View so these stay readable there too.
28108 if(smalls.length){
28109 smalls.sort(function(a,b){return a.mAng-b.mAng;});
28110 var sPad=6,sRowY=11;
28111 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)));});
28112 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;}
28113 var sLast=smalls[smalls.length-1],sOver=sLast.x+sLast.w/2-(DW-sPad);
28114 if(sOver>0)smalls.forEach(function(sm){sm.x-=sOver;});
28115 smalls.forEach(function(sm){
28116 var axx=cx+Ro*Math.cos(sm.mAng),ayy=cy+Ro*Math.sin(sm.mAng);
28117 ds+='<line class="sx-c3270469" 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" />';
28118 ds+='<text class="sx-83ac1cee" 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+'" >'+esc(sm.txt)+'</text>';
28119 });
28120 }
28121 }
28122 ds+='<text x="'+cx+'" y="'+(cy-7)+'" text-anchor="middle" font-family="'+FONT+'" font-size="21" font-weight="800" fill="#43342d">'+fmt(tot)+'</text>';
28123 ds+='<text x="'+cx+'" y="'+(cy+14)+'" text-anchor="middle" font-family="'+FONT+'" font-size="11" fill="#7b675b">code lines</text>';
28124 D.forEach(function(d,i){
28125 var ly=legYStart+i*legSpacing;
28126 var pctL=Math.round(d.code/tot*100);
28127 var ttL=String(d.lang).replace(/&/g,'&').replace(/"/g,'"');
28128 var ttV=(fmt(d.code)+' code lines ('+pctL+'%)').replace(/&/g,'&').replace(/"/g,'"');
28129 ds+='<g class="sx-83ac1cee" data-lang="'+esc(d.lang)+'" data-ttl="'+ttL+'" data-ttv="'+ttV+'" >';
28130 ds+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+(legSpacing||14)+'" fill="transparent"/>';
28131 ds+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+(COLS[i%COLS.length])+'"/>';
28132 ds+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT+'" font-size="'+Math.min(11,legSpacing-2)+'" fill="#43342d">'+esc(d.lang)+'</text>';
28133 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>';
28134 ds+='</g>';
28135 });
28136 ds+='</svg>';
28137
28138 // Horizontal stacked-bar chart — fills container width
28139 var maxT=Math.max.apply(null,D.map(function(d){return d.physical||d.code+d.comments+d.blanks;}))||1;
28140 var LW=108,BW=260,svgW=LW+BW+68;
28141 var barRhb=Math.min(48,Math.max(28,Math.floor((DH-32)/D.length)));
28142 var barBH=Math.min(32,Math.round(barRhb*0.7));
28143 var SH=DH;
28144 var barTopPad=Math.max(6,Math.round((SH-D.length*barRhb-18)/2));
28145 var bs='<svg class="sx-b882cc83" viewBox="0 0 '+svgW+' '+SH+'" width="'+svgW+'" height="'+SH+'" xmlns="http://www.w3.org/2000/svg">';
28146 D.forEach(function(d,i){
28147 var y=barTopPad+i*barRhb,x=LW;
28148 var phys=d.physical||d.code+d.comments+d.blanks;
28149 var cW=d.code/maxT*BW,cmW=d.comments/maxT*BW,blW=d.blanks/maxT*BW;
28150 var lmid=y+barBH/2+4;
28151 // Combined breakdown shown when hovering the row, the language name, or the
28152 // total at the bar end (\n becomes a line break in the tooltip).
28153 var ttv='Code: '+fmt(d.code)+'\nComments: '+fmt(d.comments)+'\nBlank: '+fmt(d.blanks)+'\nTotal: '+fmt(phys);
28154 bs+='<g class="lang-bar-row">';
28155 // Hit area ends just past the total label so empty space to the right of the
28156 // bar does not trigger the tooltip — only the name, bar and total are hot.
28157 var hitW=px(LW+phys/maxT*BW+8+(String(fmt(phys)).length*6.8)+6);
28158 bs+='<rect class="sx-83ac1cee"'+tt(d.lang,ttv)+' x="0" y="'+y+'" width="'+hitW+'" height="'+barBH+'" fill="transparent" />';
28159 bs+='<text class="sx-83ac1cee"'+tt(d.lang,ttv)+' x="'+(LW-6)+'" y="'+lmid+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="#43342d" >'+esc(d.lang)+'</text>';
28160 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 class="sx-c3270469" x="'+px(x+cW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fc+'" font-weight="700" fill="#fff" >'+fmt(d.code)+'</text>';x+=cW;}
28161 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 class="sx-c3270469" x="'+px(x+cmW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fm+'" font-weight="700" fill="#fff" >'+fmt(d.comments)+'</text>';x+=cmW;}
28162 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 class="sx-c3270469" x="'+px(x+blW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fb+'" font-weight="700" fill="#555" >'+fmt(d.blanks)+'</text>';}
28163 bs+='<text class="sx-83ac1cee"'+tt(d.lang,ttv)+' x="'+px(LW+phys/maxT*BW+8)+'" y="'+lmid+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="#7b675b" >'+fmt(phys)+'</text>';
28164 bs+='</g>';
28165 });
28166 var ly=SH-14;
28167 var totC=D.reduce(function(a,d){return a+(d.code||0);},0);
28168 var totCm=D.reduce(function(a,d){return a+(d.comments||0);},0);
28169 var totBl=D.reduce(function(a,d){return a+(d.blanks||0);},0);
28170 var totAll=totC+totCm+totBl||1;
28171 function legTT(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'"')+'"';}
28172 var ttC=legTT('Code lines',fmt(totC)+' total ('+Math.round(totC/totAll*100)+'%)');
28173 var ttCm=legTT('Comment lines',fmt(totCm)+' total ('+Math.round(totCm/totAll*100)+'%)');
28174 var ttBl=legTT('Blank lines',fmt(totBl)+' total ('+Math.round(totBl/totAll*100)+'%)');
28175 var legSt=LW+Math.max(0,Math.round((BW-194)/2));
28176 bs+='<g class="sx-83ac1cee" data-kind="code" >'
28177 +'<rect x="'+legSt+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC+'/>'
28178 +'<rect x="'+legSt+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC+'/>'
28179 +'<text x="'+(legSt+13)+'" y="'+(ly+9)+'"'+ttC+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Code</text>'
28180 +'</g>';
28181 bs+='<g class="sx-83ac1cee" data-kind="comment" >'
28182 +'<rect x="'+(legSt+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm+'/>'
28183 +'<rect x="'+(legSt+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm+'/>'
28184 +'<text x="'+(legSt+71)+'" y="'+(ly+9)+'"'+ttCm+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Comments</text>'
28185 +'</g>';
28186 bs+='<g class="sx-83ac1cee" data-kind="blank" >'
28187 +'<rect x="'+(legSt+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl+'/>'
28188 +'<rect x="'+(legSt+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl+'/>'
28189 +'<text x="'+(legSt+158)+'" y="'+(ly+9)+'"'+ttBl+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="#43342d">Blanks</text>'
28190 +'</g>';
28191 bs+='</svg>';
28192 el.innerHTML='<div class="r-lang-overview">'+
28193 '<div class="r-lang-overview-cell"><p>Code Lines by Language</p>'+ds+'</div>'+
28194 '<div class="r-lang-overview-cell sx-096e0509" ><p>Line Mix per Language</p>'+bs+'</div>'+
28195 '</div>';
28196 function wireDonutLegend(svg){
28197 if(!svg)return;
28198 // Every donut element carries data-lang: slices (path/circle), leader lines,
28199 // outside labels + % labels (text) and legend rows (g). Hovering any one of
28200 // them emphasises that language across all of them and fades the rest, so the
28201 // slice, its leader line, its label and its legend row move as one picture.
28202 var items=svg.querySelectorAll('[data-lang]');
28203 function emph(el,st){ // st: 1 = highlight, -1 = fade, 0 = reset
28204 var tag=el.tagName.toLowerCase();
28205 if(tag==='path'||tag==='circle'){
28206 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)';}
28207 else if(st===-1){el.style.opacity='0.24';el.style.filter='none';el.style.transform='none';}
28208 else{el.style.opacity='';el.style.filter='';el.style.transform='';}
28209 }else if(tag==='line'){
28210 if(st===1){el.style.opacity='1';el.style.strokeWidth='1.8';}
28211 else if(st===-1){el.style.opacity='0.1';el.style.strokeWidth='';}
28212 else{el.style.opacity='';el.style.strokeWidth='';}
28213 }else if(tag==='text'){
28214 if(st===1){el.style.opacity='1';el.style.fontWeight='800';}
28215 else if(st===-1){el.style.opacity='0.18';el.style.fontWeight='';}
28216 else{el.style.opacity='';el.style.fontWeight='';}
28217 }else{ // legend group
28218 if(st===1){el.style.opacity='1';}
28219 else if(st===-1){el.style.opacity='0.4';}
28220 else{el.style.opacity='';}
28221 }
28222 }
28223 function hl(lang){for(var i=0;i<items.length;i++){emph(items[i],items[i].getAttribute('data-lang')===lang?1:-1);}}
28224 function rst(){for(var i=0;i<items.length;i++){emph(items[i],0);}}
28225 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();});
28226 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();});
28227 svg.addEventListener('mouseout',function(e){if(e.relatedTarget&&svg.contains(e.relatedTarget))return;rst();});
28228 }
28229 function wireMixLegend(svg){
28230 if(!svg)return;
28231 var legGs=svg.querySelectorAll('g[data-kind]');
28232 var allRects=svg.querySelectorAll('rect[data-kind]');
28233 if(!legGs.length)return;
28234 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';}}
28235 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='';}}
28236 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]);}
28237 }
28238 wireDonutLegend(el.querySelector('svg'));
28239 wireMixLegend(el.querySelectorAll('svg')[1]);
28240
28241 // ── Language breakdown Full View expand ─────────────────────────────────
28242 var langOvBtn=document.getElementById('result-lang-overview-expand');
28243 if(langOvBtn){langOvBtn.addEventListener('click',function(){
28244 var src=document.getElementById('result-lang-charts');
28245 if(!src)return;
28246 var overlay=document.createElement('div');
28247 overlay.className='r-chart-modal-overlay';
28248 overlay.innerHTML='<div class="r-chart-modal sx-eb9a8e19" ><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 class="sx-9ccc4ca9" id="result-lang-overview-modal-wrap" ></div></div>';
28249 document.body.appendChild(overlay);
28250 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
28251 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
28252 var wrap=document.getElementById('result-lang-overview-modal-wrap');
28253 if(wrap){
28254 wrap.innerHTML=src.innerHTML;
28255 var svgs=wrap.querySelectorAll('svg');
28256 for(var i=0;i<svgs.length;i++){
28257 svgs[i].removeAttribute('width');
28258 svgs[i].removeAttribute('height');
28259 svgs[i].style.cssText='display:block;width:100%;height:auto;';
28260 }
28261 var ov=wrap.querySelector('.r-lang-overview');
28262 if(ov){ov.style.flexWrap='nowrap';ov.style.alignItems='stretch';}
28263 var cells=wrap.querySelectorAll('.r-lang-overview-cell');
28264 if(cells.length>0)cells[0].style.cssText='flex:1 1 0;max-width:none;justify-content:center;';
28265 if(cells.length>1)cells[1].style.cssText='flex:1 1 0;max-width:none;';
28266 wireDonutLegend(wrap.querySelector('svg'));
28267 wireMixLegend(wrap.querySelectorAll('svg')[1]);
28268 requestAnimationFrame(function(){
28269 var ss=wrap.querySelectorAll('svg');
28270 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%;';}}
28271 });
28272 }
28273 });}
28274 })();
28275
28276 // ── Extended charts (composition, scatter, semantic, submodule) ─────────
28277 (function(){
28278 var LANG_D={{ lang_chart_json|safe }};
28279 var SCAT_D={{ scatter_chart_json|safe }};
28280 var SEM_D={{ semantic_chart_json|safe }};
28281 var SUB_D={{ submodule_chart_json|safe }};
28282 var COLS=['#C45C10','#2A6846','#4472C4','#805099','#D4A017','#B23030','#2E75B6','#70AD47','#FF9900','#9E480E','#636363','#156082','#1F6E6E','#8B4513','#4169E1','#228B22','#8B008B','#FF6347','#708090','#DAA520'];
28283 var FONT='Inter,ui-sans-serif,system-ui,-apple-system,sans-serif';
28284 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();}
28285 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
28286 function px(n){return Math.round(n);}
28287 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+'"';}
28288 // Largest font size (<=10) at which `t` fits in a `w`-wide bar segment, or 0
28289 // when it cannot fit legibly even at the 6.5 floor (labels shrink to fit
28290 // rather than disappear; the SVG scales up in Full View).
28291 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;}
28292
28293 // ── Composition (horizontal stacked bars, abs or 100% pct) ────────────
28294 function renderCompositionInEl(el,mode,shOvr){
28295 if(!el||!LANG_D||!LANG_D.length)return;
28296 var OX='#C45C10',GN='#2A6846',GY='#BBBBBB';
28297 var LW=110,SH=shOvr||300;
28298 var svgW=Math.max(320,el.offsetWidth||480);
28299 var BW=Math.max(120,svgW-LW-80);
28300 var legendH=24,topPad=4;
28301 var n=LANG_D.length||1;
28302 var rowTotal=Math.floor((SH-legendH-topPad)/n);
28303 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
28304 var s='<svg class="sx-b882cc83" viewBox="0 0 '+svgW+' '+SH+'" width="'+svgW+'" height="'+SH+'" xmlns="http://www.w3.org/2000/svg">';
28305 var totC2=LANG_D.reduce(function(a,d){return a+(d.code||0);},0);
28306 var totCm2=LANG_D.reduce(function(a,d){return a+(d.comments||0);},0);
28307 var totBl2=LANG_D.reduce(function(a,d){return a+(d.blanks||0);},0);
28308 var totAll2=totC2+totCm2+totBl2||1;
28309 if(mode==='pct'){
28310 LANG_D.forEach(function(d,i){
28311 var tot2=(d.code||0)+(d.comments||0)+(d.blanks||0)||1;
28312 var cW=(d.code||0)/tot2*BW,cmW=(d.comments||0)/tot2*BW,blW=(d.blanks||0)/tot2*BW;
28313 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
28314 var lmid=y+Math.floor(bH/2)+4;
28315 var ttvc='Code: '+fmt(d.code||0)+'\nComments: '+fmt(d.comments||0)+'\nBlank: '+fmt(d.blanks||0)+'\nTotal: '+fmt(d.physical||tot2);
28316 s+='<text class="sx-83ac1cee"'+tt(d.lang,ttvc)+' x="'+(LW-5)+'" y="'+lmid+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="currentColor" >'+esc(d.lang)+'</text>';
28317 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 class="sx-c3270469" x="'+px(x+cW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fc+'" font-weight="700" fill="#fff" >'+fmt(d.code||0)+'</text>';x+=cW;}
28318 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 class="sx-c3270469" x="'+px(x+cmW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fm+'" font-weight="700" fill="#fff" >'+fmt(d.comments||0)+'</text>';x+=cmW;}
28319 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 class="sx-c3270469" x="'+px(x+blW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fb+'" font-weight="700" fill="#555" >'+fmt(d.blanks||0)+'</text>';}
28320 var pct=Math.round((d.code||0)/tot2*100);
28321 s+='<text class="sx-83ac1cee"'+tt(d.lang,ttvc)+' x="'+(LW+BW+4)+'" y="'+lmid+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor" >'+pct+'%</text>';
28322 });
28323 } else {
28324 var maxT=Math.max.apply(null,LANG_D.map(function(d){return(d.code||0)+(d.comments||0)+(d.blanks||0);}))||1;
28325 LANG_D.forEach(function(d,i){
28326 var cW=(d.code||0)/maxT*BW,cmW=(d.comments||0)/maxT*BW,blW=(d.blanks||0)/maxT*BW;
28327 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2),x=LW;
28328 var lmid=y+Math.floor(bH/2)+4;
28329 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));
28330 s+='<text class="sx-83ac1cee"'+tt(d.lang,ttvc)+' x="'+(LW-5)+'" y="'+lmid+'" text-anchor="end" font-family="'+FONT+'" font-size="11" fill="currentColor" >'+esc(d.lang)+'</text>';
28331 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 class="sx-c3270469" x="'+px(x+cW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fc+'" font-weight="700" fill="#fff" >'+fmt(d.code||0)+'</text>';x+=cW;}
28332 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 class="sx-c3270469" x="'+px(x+cmW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fm+'" font-weight="700" fill="#fff" >'+fmt(d.comments||0)+'</text>';x+=cmW;}
28333 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 class="sx-c3270469" x="'+px(x+blW/2)+'" y="'+lmid+'" text-anchor="middle" font-family="'+FONT+'" font-size="'+_fb+'" font-weight="700" fill="#555" >'+fmt(d.blanks||0)+'</text>';}
28334 s+='<text class="sx-83ac1cee"'+tt(d.lang,ttvc)+' x="'+(LW+cW+cmW+blW+4)+'" y="'+lmid+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor" >'+fmt(d.physical||(d.code||0)+(d.comments||0)+(d.blanks||0))+'</text>';
28335 });
28336 }
28337 var ly=SH-legendH+4;
28338 var legSt2=LW+Math.max(0,Math.round((BW-194)/2));
28339 function legTT2(lbl,val){return ' data-ttl="'+lbl+'" data-ttv="'+val.replace(/"/g,'"')+'"';}
28340 var ttC2=legTT2('Code lines',fmt(totC2)+' total ('+Math.round(totC2/totAll2*100)+'%)');
28341 var ttCm2=legTT2('Comment lines',fmt(totCm2)+' total ('+Math.round(totCm2/totAll2*100)+'%)');
28342 var ttBl2=legTT2('Blank lines',fmt(totBl2)+' total ('+Math.round(totBl2/totAll2*100)+'%)');
28343 s+='<g class="sx-83ac1cee" data-kind="code" >'
28344 +'<rect x="'+legSt2+'" y="'+(ly-3)+'" width="50" height="16" fill="transparent"'+ttC2+'/>'
28345 +'<rect x="'+legSt2+'" y="'+ly+'" width="9" height="9" fill="'+OX+'"'+ttC2+'/>'
28346 +'<text x="'+(legSt2+13)+'" y="'+(ly+9)+'"'+ttC2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Code</text>'
28347 +'</g>';
28348 s+='<g class="sx-83ac1cee" data-kind="comment" >'
28349 +'<rect x="'+(legSt2+58)+'" y="'+(ly-3)+'" width="82" height="16" fill="transparent"'+ttCm2+'/>'
28350 +'<rect x="'+(legSt2+58)+'" y="'+ly+'" width="9" height="9" fill="'+GN+'"'+ttCm2+'/>'
28351 +'<text x="'+(legSt2+71)+'" y="'+(ly+9)+'"'+ttCm2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Comments</text>'
28352 +'</g>';
28353 s+='<g class="sx-83ac1cee" data-kind="blank" >'
28354 +'<rect x="'+(legSt2+145)+'" y="'+(ly-3)+'" width="55" height="16" fill="transparent"'+ttBl2+'/>'
28355 +'<rect x="'+(legSt2+145)+'" y="'+ly+'" width="9" height="9" fill="'+GY+'"'+ttBl2+'/>'
28356 +'<text x="'+(legSt2+158)+'" y="'+(ly+9)+'"'+ttBl2+' font-family="'+FONT+'" font-size="10" font-weight="700" fill="currentColor">Blanks</text>'
28357 +'</g>';
28358 s+='</svg>';
28359 el.innerHTML=s;
28360 wireMixLegendEl(el);
28361 }
28362 function wireMixLegendEl(container){
28363 var svg=container&&container.querySelector('svg');
28364 if(!svg)return;
28365 var legGs=svg.querySelectorAll('g[data-kind]');
28366 var allRects=svg.querySelectorAll('rect[data-kind]');
28367 if(!legGs.length)return;
28368 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';}}
28369 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='';}}
28370 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]);}
28371 }
28372 function renderComposition(mode){renderCompositionInEl(document.getElementById('r-composition-chart'),mode,0);}
28373 renderComposition('abs');
28374 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(btn){
28375 btn.addEventListener('click',function(){
28376 Array.prototype.slice.call(document.querySelectorAll('[data-rcomp]')).forEach(function(b){b.classList.remove('active');});
28377 btn.classList.add('active');
28378 renderComposition(btn.getAttribute('data-rcomp'));
28379 });
28380 });
28381
28382 // ── Scatter: Files vs Code Lines (bubble = physical lines) ─────────────
28383 function wireScatterLegend(container){
28384 var svg=container&&container.querySelector('svg');
28385 if(!svg)return;
28386 var legGs=svg.querySelectorAll('g[data-lang]');
28387 var circs=svg.querySelectorAll('circle[data-lang]');
28388 var labs=svg.querySelectorAll('text[data-lang]');
28389 if(!legGs.length)return;
28390 // Raise an element to the top of its parent so the hovered bubble and its
28391 // name/number labels sit above overlapping neighbours (clustered bubbles
28392 // otherwise bury the one you are trying to read).
28393 function raise(el){if(el&&el.parentNode)el.parentNode.appendChild(el);}
28394 function hl(lang){
28395 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';}}
28396 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';}}
28397 for(var j=0;j<legGs.length;j++){legGs[j].style.opacity=legGs[j].getAttribute('data-lang')===lang?'1':'0.38';}}
28398 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='';}}
28399 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]);}
28400 }
28401 function renderScatterInEl(el,hOvr){
28402 if(!el||!SCAT_D||!SCAT_D.length)return;
28403 var n=SCAT_D.length;
28404 var H=hOvr||300,PL=52,PB=36,PT=44;
28405 var W=Math.max(320,el.offsetWidth||480);
28406 var cH=H-PT-PB;
28407 // Legend: max 2 columns, fills vertical space. The compact card shows the
28408 // top languages by code lines plus a "+N more" row linking to Full View;
28409 // Full View (hOvr set) shows every language across up to 2 tall columns.
28410 var compact=!hOvr;
28411 var availH=Math.max(120,H-24);
28412 var rowsFit=Math.max(2,Math.floor(availH/18));
28413 var legTrunc=compact&&(n>2*rowsFit);
28414 var legShown=legTrunc?(2*rowsFit-1):n;
28415 var legTotal=legTrunc?(2*rowsFit):n;
28416 var legCols=legTotal>Math.min(rowsFit,18)?2:1;
28417 var legPerCol=Math.ceil(legTotal/legCols);
28418 var legRowH=Math.max(14,Math.min(30,Math.floor(availH/legPerCol)));
28419 var legColW=hOvr?144:130;
28420 var LG=26;
28421 var legW=legCols*legColW;
28422 var cW=W-PL-LG-legW;
28423 var legOrder=SCAT_D.map(function(_,i){return i;}).sort(function(a,b){return (SCAT_D[b].code||0)-(SCAT_D[a].code||0);});
28424 var maxF=Math.max.apply(null,SCAT_D.map(function(d){return d.files;}))||1;
28425 var maxC=Math.max.apply(null,SCAT_D.map(function(d){return d.code;}))||1;
28426 var maxP=Math.max.apply(null,SCAT_D.map(function(d){return d.physical;}))||1;
28427 // log1p scale on X to prevent outlier files-count from collapsing all others to the left
28428 var logMaxF=Math.log1p(maxF);
28429 var s='<svg class="scat-svg sx-b882cc83" viewBox="0 0 '+W+' '+H+'" width="'+W+'" height="'+H+'" xmlns="http://www.w3.org/2000/svg">';
28430 // Smooth the legend-hover fade so bubbles + labels animate together.
28431 s+='<style>.scat-svg circle,.scat-svg text,.scat-svg g{transition:opacity .2s ease,filter .2s ease;}</style>';
28432 // Y grid lines (linear)
28433 [0,0.25,0.5,0.75,1].forEach(function(t){
28434 var y=PT+cH*(1-t);
28435 s+='<line x1="'+PL+'" y1="'+px(y)+'" x2="'+(PL+cW)+'" y2="'+px(y)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
28436 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>';
28437 });
28438 // X grid lines (log1p scale — tick labels show actual file counts at those positions)
28439 [0,0.25,0.5,0.75,1].forEach(function(t){
28440 var x=PL+cW*t;
28441 var xVal=t>0?Math.round(Math.expm1(t*logMaxF)):0;
28442 s+='<line x1="'+px(x)+'" y1="'+PT+'" x2="'+px(x)+'" y2="'+(PT+cH)+'" stroke="rgba(128,128,128,0.18)" stroke-width="1"/>';
28443 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>';
28444 });
28445 // Full View (hOvr set) has the vertical room to show the per-bubble value
28446 // line; the compact card shows only the language label to avoid the
28447 // overlapping-label clutter seen when bubbles cluster together.
28448 var showVal=!!hOvr;
28449 SCAT_D.forEach(function(d,i){
28450 // X uses log1p so outlier languages (many files) don't push others to the far left
28451 var cx2=PL+(logMaxF>0?Math.log1p(Math.max(1,d.files))/logMaxF:0.5)*cW;
28452 var cy2=PT+cH-d.code/maxC*cH;
28453 var r=Math.max(4,Math.sqrt(d.physical/maxP)*18);
28454 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"/>';
28455 // Label(s) centred directly above bubble; clamp to stay inside the plot top.
28456 if(showVal){
28457 var ty2=Math.max(24,px(cy2)-px(r)-3);
28458 var ty1=Math.max(12,ty2-14);
28459 s+='<text class="sx-c3270469" 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" >'+esc(d.lang)+'</text>';
28460 s+='<text class="sx-c3270469" 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" >'+fmt(d.code)+'</text>';
28461 }else{
28462 var ly2=Math.max(12,px(cy2)-px(r)-3);
28463 s+='<text class="sx-c3270469" 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" >'+esc(d.lang)+'</text>';
28464 }
28465 });
28466 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>';
28467 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>';
28468 // Legend (right side — top languages, max 2 columns, fills height)
28469 var legX=PL+cW+LG;
28470 var legBlockH=legPerCol*legRowH;
28471 var legY0=Math.max(8,Math.floor((H-legBlockH)/2));
28472 function legXY(k){return {x:legX+Math.floor(k/legPerCol)*legColW,y:legY0+(k%legPerCol)*legRowH};}
28473 for(var lk=0;lk<legShown;lk++){
28474 var oi=legOrder[lk],ld=SCAT_D[oi],lcol=COLS[oi%COLS.length];
28475 var lp=legXY(lk),ly=lp.y+Math.floor(legRowH/2);
28476 s+='<g class="sx-83ac1cee" data-lang="'+esc(ld.lang)+'" data-ttl="'+esc(ld.lang)+'" data-ttv="'+esc(fmt(ld.files)+' files · '+fmt(ld.code)+' code lines')+'" >';
28477 s+='<rect x="'+lp.x+'" y="'+lp.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
28478 s+='<rect class="sx-c3270469" x="'+lp.x+'" y="'+(ly-6)+'" width="22" height="12" rx="2" fill="'+lcol+'" opacity="0.88" />';
28479 s+='<text class="sx-c3270469" x="'+(lp.x+28)+'" y="'+(ly+4)+'" font-family="'+FONT+'" font-size="12" font-weight="400" fill="currentColor" >'+esc(ld.lang)+'</text>';
28480 s+='</g>';
28481 }
28482 if(legTrunc){
28483 var pm=legXY(legShown),lym=pm.y+Math.floor(legRowH/2);
28484 s+='<g class="sx-83ac1cee" data-more="1" >';
28485 s+='<rect x="'+pm.x+'" y="'+pm.y+'" width="'+(legColW-6)+'" height="'+legRowH+'" fill="transparent"/>';
28486 s+='<rect class="sx-c3270469" x="'+pm.x+'" y="'+(lym-6)+'" width="22" height="12" rx="2" fill="#9a8c82" opacity="0.45" />';
28487 s+='<text class="sx-c3270469" x="'+(pm.x+28)+'" y="'+(lym+4)+'" font-family="'+FONT+'" font-size="12" font-style="italic" fill="currentColor" opacity="0.8" >+'+(n-legShown)+' more</text>';
28488 s+='</g>';
28489 }
28490 s+='</svg>';
28491 el.innerHTML=s;
28492 wireScatterLegend(el);
28493 var moreEl=el.querySelector('g[data-more]');
28494 if(moreEl)moreEl.addEventListener('click',function(){var b=document.getElementById('r-scatter-expand');if(b)b.click();});
28495 }
28496 renderScatterInEl(document.getElementById('r-scatter-chart'),0);
28497
28498 // ── Semantic: horizontal bar chart (one bar per language) ─────────────
28499 // Horizontal layout avoids the portrait-aspect scaling bug that plagued
28500 // the old vertical column layout on wide containers.
28501 function renderSemanticInEl(el,key,sh){
28502 if(!el||!SEM_D||!SEM_D.length)return;
28503 var n2=SEM_D.length||1;
28504 var LW=112,SH=sh||Math.max(180,n2*28+26);
28505 var svgW=Math.max(320,el.offsetWidth||480);
28506 var BW=Math.max(120,svgW-LW-80);
28507 var topPad=4,botPad=14;
28508 var rowTotal2=Math.floor((SH-topPad-botPad)/n2);
28509 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal2*0.65)));
28510 var maxV=Math.max.apply(null,SEM_D.map(function(d){return d[key]||0;}))||1;
28511 var s='<svg class="sx-b882cc83" viewBox="0 0 '+svgW+' '+SH+'" width="'+svgW+'" height="'+SH+'" xmlns="http://www.w3.org/2000/svg">';
28512 SEM_D.forEach(function(d,i){
28513 var v=d[key]||0,bw=v/maxV*BW,y=topPad+i*rowTotal2+Math.floor((rowTotal2-bH)/2);
28514 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>';
28515 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"/>';
28516 s+='<text class="sx-c3270469" x="'+(LW+px(bw)+6)+'" y="'+(y+Math.floor(bH/2)+4)+'" font-family="'+FONT+'" font-size="11" font-weight="700" fill="currentColor" >'+fmt(v)+'</text>';
28517 });
28518 s+='</svg>';
28519 el.innerHTML=s;
28520 }
28521 function renderSemantic(key){renderSemanticInEl(document.getElementById('r-semantic-chart'),key,0);}
28522 var semSel=document.getElementById('r-semantic-metric');
28523 if(semSel){renderSemantic('functions');semSel.addEventListener('change',function(){renderSemantic(semSel.value);syncRowHeights();});}
28524 var semExpand=document.getElementById('r-semantic-expand');
28525 if(semExpand){
28526 semExpand.addEventListener('click',function(){
28527 var key=semSel?semSel.value:'functions';
28528 var n=SEM_D.length||1;
28529 var maxH=Math.max(360,Math.floor(window.innerHeight*0.82)-130);
28530 var modalH=Math.min(Math.max(360,n*38+60),maxH);
28531 var overlay=document.createElement('div');
28532 overlay.className='r-chart-modal-overlay';
28533 var optHtml=
28534 '<option value="functions"'+(key==='functions'?' selected':'')+'>Functions</option>'
28535 +'<option value="classes"'+(key==='classes'?' selected':'')+'>Classes</option>'
28536 +'<option value="variables"'+(key==='variables'?' selected':'')+'>Variables</option>'
28537 +'<option value="imports"'+(key==='imports'?' selected':'')+'>Imports</option>'
28538 +'<option value="tests"'+(key==='tests'?' selected':'')+'>Tests</option>';
28539 overlay.innerHTML='<div class="r-chart-modal sx-f4f5192a" ><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" data-sx-style="height:'+modalH+'px;width:100%;overflow:hidden;"></div></div>';
28540 document.body.appendChild(overlay);
28541 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
28542 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
28543 var modalEl=document.getElementById('r-sem-modal-chart');
28544 if(modalEl){setTimeout(function(){renderSemanticInEl(modalEl,key,modalH);},30);}
28545 var modalSel=document.getElementById('r-sem-modal-metric');
28546 if(modalSel){modalSel.addEventListener('change',function(){renderSemanticInEl(modalEl,modalSel.value,modalH);});}
28547 });
28548 }
28549
28550 // ── Expand buttons: re-render charts at large size inside modal ──────────
28551 (function(){
28552 function makeExpandModal(title,mH,subtitle,ctrlHtml){
28553 var overlay=document.createElement('div');
28554 overlay.className='r-chart-modal-overlay';
28555 var subHtml=subtitle?'<span class="r-chart-modal-subtitle">'+subtitle+'</span>':'';
28556 var hdr='<div class="r-modal-header"><span class="r-chart-modal-title">'+title+' \u2014 Full View</span>'+(ctrlHtml||'')+'</div>';
28557 overlay.innerHTML='<div class="r-chart-modal sx-f4f5192a" ><button class="r-chart-modal-close" aria-label="Close">×</button>'+hdr+subHtml+'<div class="r-expand-modal-chart" data-sx-style="width:100%;height:'+mH+'px;overflow:hidden;"></div></div>';
28558 document.body.appendChild(overlay);
28559 overlay.querySelector('.r-chart-modal-close').addEventListener('click',function(){document.body.removeChild(overlay);});
28560 overlay.addEventListener('click',function(e){if(e.target===overlay)document.body.removeChild(overlay);});
28561 return overlay.querySelector('.r-expand-modal-chart');
28562 }
28563 function capH(h){return Math.min(h,Math.max(360,Math.floor(window.innerHeight*0.82)-130));}
28564 var compExpandBtn=document.getElementById('r-composition-expand');
28565 if(compExpandBtn){compExpandBtn.addEventListener('click',function(){
28566 var mode=document.querySelector('[data-rcomp].active');var modeKey=mode?mode.getAttribute('data-rcomp'):'abs';
28567 var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
28568 var ctrlHtml='<button class="r-chart-tab'+(modeKey==='abs'?' active':'')+'" data-mcomp="abs">Absolute</button>'
28569 +'<button class="r-chart-tab'+(modeKey==='pct'?' active':'')+'" data-mcomp="pct">100% Normalized</button>';
28570 var wrap=makeExpandModal('Language Composition',mH,null,ctrlHtml);
28571 if(wrap){
28572 setTimeout(function(){renderCompositionInEl(wrap,modeKey,mH);},30);
28573 Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(btn){
28574 btn.addEventListener('click',function(){
28575 Array.prototype.slice.call(wrap.parentNode.querySelectorAll('[data-mcomp]')).forEach(function(b){b.classList.remove('active');});
28576 btn.classList.add('active');
28577 renderCompositionInEl(wrap,btn.getAttribute('data-mcomp'),mH);
28578 });
28579 });
28580 }
28581 });}
28582 var scatExpandBtn=document.getElementById('r-scatter-expand');
28583 if(scatExpandBtn){scatExpandBtn.addEventListener('click',function(){
28584 var wrap=makeExpandModal('Files vs Code Lines',capH(672),'File count vs SLOC per language');
28585 if(wrap)setTimeout(function(){renderScatterInEl(wrap,560);},30);
28586 });}
28587 var densExpandBtn=document.getElementById('r-density-expand');
28588 if(densExpandBtn){densExpandBtn.addEventListener('click',function(){
28589 var n=LANG_D.length||1;var mH=capH(Math.max(360,n*38+60));
28590 var wrap=makeExpandModal('Comment Density',mH,'Comment ratio per language');
28591 if(wrap)setTimeout(function(){renderDensityInEl(wrap,mH);},30);
28592 });}
28593 var avgExpandBtn=document.getElementById('r-avglines-expand');
28594 if(avgExpandBtn){avgExpandBtn.addEventListener('click',function(){
28595 var n=LANG_D.filter(function(d){return(d.files||0)>0;}).length||1;var mH=capH(Math.max(360,n*38+60));
28596 var wrap=makeExpandModal('Avg Lines per File',mH,'Average code lines per file');
28597 if(wrap)setTimeout(function(){renderAvgLinesInEl(wrap,mH);},30);
28598 });}
28599 var subExpandBtn=document.getElementById('r-submodule-expand');
28600 if(subExpandBtn){subExpandBtn.addEventListener('click',function(){
28601 var key=subSel?subSel.value:'code';var sort=sortSel?sortSel.value:'desc';
28602 var n=(SUB_D.length+1)||1;var mH=capH(Math.max(360,n*32+100));
28603 var metCtrl=
28604 '<select class="r-chart-select" id="r-sub-modal-metric">'
28605 +'<option value="code"'+(key==='code'?' selected':'')+'>Code Lines</option>'
28606 +'<option value="comment"'+(key==='comment'?' selected':'')+'>Comments</option>'
28607 +'<option value="blank"'+(key==='blank'?' selected':'')+'>Blank Lines</option>'
28608 +'<option value="physical"'+(key==='physical'?' selected':'')+'>Physical Lines</option>'
28609 +'<option value="files"'+(key==='files'?' selected':'')+'>Files</option>'
28610 +'</select>';
28611 var sortCtrl=
28612 '<select class="r-chart-select" id="r-sub-modal-sort">'
28613 +'<option value="desc"'+(sort==='desc'?' selected':'')+'>Value \u2193</option>'
28614 +'<option value="asc"'+(sort==='asc'?' selected':'')+'>Value \u2191</option>'
28615 +'<option value="name"'+(sort==='name'?' selected':'')+'>Name A\u2192Z</option>'
28616 +'</select>';
28617 var wrap=makeExpandModal('Repository Overview',mH,null,metCtrl+sortCtrl);
28618 if(wrap){
28619 setTimeout(function(){renderSubmoduleInEl(wrap,key,sort,mH);},30);
28620 var mSub=wrap.parentNode.querySelector('#r-sub-modal-metric');
28621 var mSort=wrap.parentNode.querySelector('#r-sub-modal-sort');
28622 function reRenderSub(){renderSubmoduleInEl(wrap,mSub?mSub.value:'code',mSort?mSort.value:'desc',mH);}
28623 if(mSub)mSub.addEventListener('change',reRenderSub);
28624 if(mSort)mSort.addEventListener('change',reRenderSub);
28625 }
28626 });}
28627 })();
28628
28629 // ── Comment Density: comments / (code + comments) per language ───────────
28630 function renderDensityInEl(el,shOvr){
28631 if(!el||!LANG_D||!LANG_D.length)return;
28632 var n=LANG_D.length||1;
28633 var LW=112,SH=shOvr||Math.max(180,n*28+26);
28634 var svgW=Math.max(320,el.offsetWidth||480);
28635 var BW=Math.max(120,svgW-LW-80);
28636 var topPad=4,botPad=26;
28637 var rowTotal=Math.floor((SH-topPad-botPad)/n);
28638 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
28639 var densities=LANG_D.map(function(d){
28640 var sig=(d.code||0)+(d.comments||0);
28641 return sig>0?(d.comments||0)/sig:0;
28642 });
28643 var maxDen=Math.max.apply(null,densities)||1;
28644 var s='<svg class="sx-b882cc83" viewBox="0 0 '+svgW+' '+SH+'" width="'+svgW+'" height="'+SH+'" xmlns="http://www.w3.org/2000/svg">';
28645 LANG_D.forEach(function(d,i){
28646 var den=densities[i],bw=den/maxDen*BW;
28647 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
28648 var pct=Math.round(den*100);
28649 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>';
28650 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"/>';
28651 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
28652 s+='<text class="sx-c3270469" 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" >'+pct+'%</text>';
28653 });
28654 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>';
28655 s+='</svg>';
28656 el.innerHTML=s;
28657 }
28658 function renderDensity(){renderDensityInEl(document.getElementById('r-density-chart'),0);}
28659 renderDensity();
28660
28661 // ── Avg Lines per File: code / files per language ─────────────────────
28662 function renderAvgLinesInEl(el,shOvr){
28663 if(!el||!LANG_D||!LANG_D.length)return;
28664 var data=LANG_D.filter(function(d){return(d.files||0)>0;}).slice();
28665 data.sort(function(a,b){return(b.code/b.files)-(a.code/a.files);});
28666 var n=data.length||1;
28667 var LW=112,SH=shOvr||Math.max(180,n*28+26);
28668 var svgW=Math.max(320,el.offsetWidth||480);
28669 var BW=Math.max(120,svgW-LW-80);
28670 var topPad=4,botPad=26;
28671 var rowTotal=Math.floor((SH-topPad-botPad)/n);
28672 var bH=Math.min(22,Math.max(10,Math.floor(rowTotal*0.65)));
28673 var avgs=data.map(function(d){return(d.code||0)/(d.files||1);});
28674 var maxAvg=Math.max.apply(null,avgs)||1;
28675 var s='<svg class="sx-b882cc83" viewBox="0 0 '+svgW+' '+SH+'" width="'+svgW+'" height="'+SH+'" xmlns="http://www.w3.org/2000/svg">';
28676 data.forEach(function(d,i){
28677 var avg=avgs[i],bw=avg/maxAvg*BW;
28678 var y=topPad+i*rowTotal+Math.floor((rowTotal-bH)/2);
28679 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>';
28680 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"/>';
28681 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
28682 s+='<text class="sx-c3270469" 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" >'+fmt(Math.round(avg))+'</text>';
28683 });
28684 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>';
28685 s+='</svg>';
28686 el.innerHTML=s;
28687 }
28688 function renderAvgLines(){renderAvgLinesInEl(document.getElementById('r-avglines-chart'),0);}
28689 renderAvgLines();
28690
28691 // ── Repository Overview: overall row + per-submodule rows ────────────
28692 function renderSubmoduleInEl(el,key,sort,shOvr){
28693 if(!el)return;
28694 var overall={
28695 name:'Overall',
28696 code:{{ code_lines }},
28697 comment:{{ comment_lines }},
28698 blank:{{ blank_lines }},
28699 physical:{{ physical_lines }},
28700 files:{{ files_analyzed }},
28701 isOverall:true
28702 };
28703 var subs=SUB_D.slice();
28704 if(sort==='desc')subs.sort(function(a,b){return(b[key]||0)-(a[key]||0);});
28705 else if(sort==='asc')subs.sort(function(a,b){return(a[key]||0)-(b[key]||0);});
28706 else subs.sort(function(a,b){return(a.name||'').localeCompare(b.name||'');});
28707 var data=[overall].concat(subs);
28708 var sepH=subs.length>0?14:0;
28709 var naturalH=data.length*32+sepH+16;
28710 var SH=shOvr||Math.max(100,naturalH);
28711 var svgW=Math.max(320,el.offsetWidth||480);
28712 var LW=116,BW=Math.max(200,svgW-LW-54);
28713 var maxV=Math.max.apply(null,data.map(function(d){return d[key]||0;}))||1;
28714 var OVERALL_COL='#6b7280';
28715 var topPad=4,botPad=8;
28716 var rowSlot=Math.floor((SH-topPad-botPad-sepH)/data.length);
28717 var bH=Math.min(22,Math.max(10,Math.floor(rowSlot*0.65)));
28718 var s='<svg class="sx-b882cc83" viewBox="0 0 '+svgW+' '+SH+'" width="'+svgW+'" height="'+SH+'" xmlns="http://www.w3.org/2000/svg">';
28719 var yOff=topPad;
28720 data.forEach(function(d,i){
28721 var v=d[key]||0,bw=v/maxV*BW;
28722 var y=yOff+Math.floor((rowSlot-bH)/2);
28723 var col=d.isOverall?OVERALL_COL:COLS[(i-1)%COLS.length];
28724 var label=d.name||d.path||'?';
28725 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>';
28726 if(bw>0.5)s+='<rect'+tt(label,fmt(v))+' x="'+LW+'" y="'+y+'" width="'+px(bw)+'" height="'+bH+'" fill="'+col+'" rx="3"/>';
28727 else s+='<rect x="'+LW+'" y="'+y+'" width="2" height="'+bH+'" fill="rgba(128,128,128,0.18)" rx="1"/>';
28728 s+='<text class="sx-c3270469" 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" >'+fmt(v)+'</text>';
28729 yOff+=rowSlot;
28730 if(d.isOverall&&subs.length>0){
28731 yOff+=sepH;
28732 }
28733 });
28734 s+='</svg>';
28735 el.innerHTML=s;
28736 }
28737 function renderSubmodule(key,sort){renderSubmoduleInEl(document.getElementById('r-submodule-chart'),key,sort,0);}
28738 var subSel=document.getElementById('r-sub-metric');
28739 var sortSel=document.getElementById('r-sub-sort');
28740 renderSubmodule('code','desc');
28741 if(subSel){
28742 subSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel?sortSel.value:'desc');syncRowHeights();});
28743 if(sortSel)sortSel.addEventListener('change',function(){renderSubmodule(subSel.value,sortSel.value);syncRowHeights();});
28744 }
28745
28746 // Equalise heights within each chart row: if one chart in a grid row is taller
28747 // than its neighbour, re-render the shorter one at the taller height so bars fill
28748 // the available vertical space instead of leaving a gap.
28749 function syncRowHeights(){
28750 var avgEl=document.getElementById('r-avglines-chart');
28751 var subEl=document.getElementById('r-submodule-chart');
28752 if(avgEl&&subEl){
28753 var avgSvg=avgEl.querySelector('svg');
28754 var subSvg=subEl.querySelector('svg');
28755 if(avgSvg&&subSvg){
28756 var avgH=parseInt(avgSvg.getAttribute('height')||'0',10);
28757 var subH=parseInt(subSvg.getAttribute('height')||'0',10);
28758 var key=subSel?subSel.value||'code':'code';
28759 var sort=sortSel?sortSel.value:'desc';
28760 if(subH>avgH+10){renderAvgLinesInEl(avgEl,subH);}
28761 else if(avgH>subH+10){renderSubmoduleInEl(subEl,key,sort,avgH);}
28762 }
28763 }
28764 var semEl=document.getElementById('r-semantic-chart');
28765 var denEl=document.getElementById('r-density-chart');
28766 if(semEl&&denEl){
28767 var semSvg=semEl.querySelector('svg');
28768 var denSvg=denEl.querySelector('svg');
28769 if(semSvg&&denSvg){
28770 var semH2=parseInt(semSvg.getAttribute('height')||'0',10);
28771 var denH2=parseInt(denSvg.getAttribute('height')||'0',10);
28772 if(denH2>semH2+10){renderSemanticInEl(semEl,semSel?semSel.value:'functions',denH2);}
28773 else if(semH2>denH2+10){renderDensityInEl(denEl,semH2);}
28774 }
28775 }
28776 }
28777 syncRowHeights();
28778
28779 // Re-render all SVG charts when the window is resized so bars fill the card.
28780 var _rResizeTimer;
28781 window.addEventListener('resize',function(){
28782 clearTimeout(_rResizeTimer);
28783 _rResizeTimer=setTimeout(function(){
28784 var rcompBtn=document.querySelector('[data-rcomp].active');
28785 renderComposition(rcompBtn?rcompBtn.getAttribute('data-rcomp'):'abs');
28786 renderScatterInEl(document.getElementById('r-scatter-chart'),0);
28787 if(semSel)renderSemantic(semSel.value||'functions');
28788 renderDensity();
28789 renderAvgLines();
28790 renderSubmodule(subSel?subSel.value||'code':'code',sortSel?sortSel.value:'desc');
28791 syncRowHeights();
28792 },120);
28793 });
28794 })();
28795
28796 (function randomizeWatermarks() {
28797 var wms = Array.prototype.slice.call(document.querySelectorAll(".background-watermarks img"));
28798 if (!wms.length) return;
28799 var placed = [];
28800 function tooClose(top, left) {
28801 for (var i = 0; i < placed.length; i++) {
28802 var dt = Math.abs(placed[i][0] - top);
28803 var dl = Math.abs(placed[i][1] - left);
28804 if (dt < 20 && dl < 18) return true;
28805 }
28806 return false;
28807 }
28808 function pick(leftBand) {
28809 for (var attempt = 0; attempt < 50; attempt++) {
28810 var top = Math.random() * 85 + 5;
28811 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
28812 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
28813 }
28814 var top = Math.random() * 85 + 5;
28815 var left = leftBand ? Math.random() * 22 + 1 : Math.random() * 22 + 72;
28816 placed.push([top, left]);
28817 return [top, left];
28818 }
28819 var angles = [-25, -15, -8, 0, 8, 15, 25, -20, 20, -10, 10, -5];
28820 var half = Math.floor(wms.length / 2);
28821 wms.forEach(function (img, i) {
28822 var pos = pick(i < half);
28823 var size = Math.floor(Math.random() * 100 + 160);
28824 var rot = angles[i % angles.length] + (Math.random() * 6 - 3);
28825 var op = (Math.random() * 0.06 + 0.07).toFixed(2);
28826 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;
28827 });
28828 })();
28829
28830 (function spawnCodeParticles() {
28831 var container = document.getElementById('code-particles');
28832 if (!container) return;
28833 var snippets = ['NUM sloc','fn analyze()','code_lines','0 mixed','blanks: NUM','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','NUM physical','files: NUM','NUM comments','cargo build','Ok(run)','Vec<GEN>','match lang','fn main()','.rs .go .py','sloc_core','render_html','NUM code','HashMap<GEN, GN2>','Option<GEN>','&str','author AUT','last_change','ratio 0.CPX','complexity CPX','tokens: NUM','?; // try','let owner','commits: NUM','#[tokio::test]','assert_eq!','PCT% cover','blame AUT','churn PCT%','HEAD~CPX','dedup PCT%','cyclomatic CPX','impl Iterator','.await','+NUM -NM2','ownership','Span<GEN>','pub struct','trait Lang','unsafe','mixed: NUM','anyhow::Result','fn detect()','PathBuf','+NUM loc','coverage PCT%','physical: NUM','ULOC NUM','dup PCT%','let mut run']; var __GT=['Author','String','Token','FileRecord','Lang','u32','usize','PathBuf','Commit','Line','Span','Metric','Record','u64','char','Utf8','Ratio','Blame']; var __AN=['nima','core','web','cli','git','lang','ci','bot','sloc'];
28834 for (var i = 0; i < 44; i++) {
28835 (function(idx) {
28836 var el = document.createElement('span');
28837 el.className = 'code-particle';
28838 el.textContent = snippets[idx % snippets.length].split('NUM').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('NM2').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('GEN').join(__GT[Math.floor(Math.random()*__GT.length)]).split('GN2').join(__GT[Math.floor(Math.random()*__GT.length)]).split('AUT').join(__AN[Math.floor(Math.random()*__AN.length)]).split('CPX').join(''+Math.floor(Math.random()*40+1)).split('PCT').join(''+Math.floor(Math.random()*44+1));
28839 var left = Math.random() * 94 + 2;
28840 var top = Math.random() * 88 + 6;
28841 var dur = (Math.random() * 10 + 9).toFixed(1);
28842 var delay = (Math.random() * 18).toFixed(1);
28843 var rot = (Math.random() * 26 - 13).toFixed(1);
28844 var op = (Math.random() * 0.108 + 0.072).toFixed(3);
28845 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';
28846 container.appendChild(el);
28847 })(i);
28848 }
28849 })();
28850
28851 {% if pdf_generating %}
28852 // Poll for PDF readiness and swap the disabled button to a live link once done.
28853 (function() {
28854 var openBtn = document.getElementById('pdf-open-btn');
28855 var dlBtn = document.getElementById('pdf-download-btn');
28856 function checkPdf() {
28857 fetch('/api/runs/{{ run_id }}/pdf-status')
28858 .then(function(r) { return r.json(); })
28859 .then(function(d) {
28860 if (d.ready) {
28861 if (openBtn) {
28862 var a = document.createElement('a');
28863 a.className = 'button';
28864 a.id = 'pdf-open-btn';
28865 a.href = '/runs/pdf/{{ run_id }}';
28866 a.target = '_blank';
28867 a.rel = 'noopener';
28868 a.textContent = 'Open PDF';
28869 openBtn.replaceWith(a);
28870 }
28871 if (dlBtn) { dlBtn.style.opacity = ''; dlBtn.style.pointerEvents = ''; }
28872 } else {
28873 setTimeout(checkPdf, 3000);
28874 }
28875 })
28876 .catch(function() { setTimeout(checkPdf, 5000); });
28877 }
28878 setTimeout(checkPdf, 3000);
28879 })();
28880 {% endif %}
28881
28882 })();
28883 </script>
28884 <script nonce="{{ csp_nonce }}">
28885 (function(){
28886 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'}];
28887 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);});}
28888 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
28889 function init(){
28890 var btn=document.getElementById('settings-btn');if(!btn)return;
28891 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
28892 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 class="sx-dec3b281" ><div class="settings-modal-label sx-c500155b" >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>';
28893 document.body.appendChild(m);
28894 var g=document.getElementById('scheme-grid');
28895 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);});
28896 var cl=document.getElementById('settings-close');
28897 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);});})();
28898 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');});
28899 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
28900 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
28901 }
28902 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
28903 }());
28904 </script>
28905 <footer class="site-footer">
28906 local code analysis - metrics, history and reports
28907 · <em class="footer-mode sx-e01b0d98" id="footer-mode" >oxide-sloc v{{ version }} — Mode: Local</em>
28908 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
28909 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
28910 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
28911 · <a href="/report-bug" rel="noopener">Report a Bug</a> · <a href="/api-docs" rel="noopener">REST API</a>
28912 </footer>
28913 {% if confluence_configured %}
28914 <script nonce="{{ csp_nonce }}">
28915 (function() {
28916 var postBtn = document.getElementById('postConfluenceBtn');
28917 var copyBtn = document.getElementById('copyWikiBtn');
28918 var modal = document.getElementById('confluenceModal');
28919 if (!postBtn || !modal) return;
28920
28921 postBtn.addEventListener('click', function() {
28922 document.getElementById('confStatus').style.display = 'none';
28923 modal.style.display = 'flex';
28924 });
28925 document.getElementById('confCancelBtn').addEventListener('click', function() {
28926 modal.style.display = 'none';
28927 });
28928 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
28929
28930 document.getElementById('confSubmitBtn').addEventListener('click', async function() {
28931 var btn = this;
28932 btn.disabled = true;
28933 var status = document.getElementById('confStatus');
28934 status.style.display = 'block';
28935 status.style.background = '#dbeafe';
28936 status.style.color = '#1e40af';
28937 status.textContent = 'Posting to Confluence\u2026';
28938 var resp = await fetch('/api/confluence/post', {
28939 method: 'POST',
28940 headers: { 'Content-Type': 'application/json' },
28941 body: JSON.stringify({
28942 run_id: '{{ run_id }}',
28943 page_title: document.getElementById('confPageTitle').value.trim() || 'OxideSLOC Report',
28944 report_url: document.getElementById('confReportUrl').value.trim() || null
28945 })
28946 });
28947 var data = await resp.json();
28948 if (data.ok) {
28949 status.style.background = '#dcfce7'; status.style.color = '#166534';
28950 status.textContent = 'Posted! Page ID: ' + data.page_id;
28951 } else {
28952 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
28953 status.textContent = 'Error: ' + (data.error || 'Unknown error');
28954 }
28955 btn.disabled = false;
28956 });
28957
28958 if (copyBtn) {
28959 copyBtn.addEventListener('click', async function() {
28960 var resp = await fetch('/api/confluence/wiki-markup?run_id={{ run_id }}');
28961 if (!resp.ok) { alert('Could not load markup. Try again.'); return; }
28962 var text = await resp.text();
28963 try {
28964 await navigator.clipboard.writeText(text);
28965 var orig = copyBtn.textContent;
28966 copyBtn.textContent = 'Copied!';
28967 setTimeout(function() { copyBtn.textContent = orig; }, 2000);
28968 } catch(e) {
28969 alert('Clipboard write failed \u2014 check browser permissions.');
28970 }
28971 });
28972 }
28973 })();
28974 </script>
28975 {% endif %}
28976 <script nonce="{{ csp_nonce }}">
28977 (function() {
28978 var deleteBtn = document.getElementById('delete-run-btn');
28979 var modal = document.getElementById('delete-run-modal');
28980 var cancelBtn = document.getElementById('delete-run-cancel');
28981 var confirmBtn= document.getElementById('delete-run-confirm');
28982 if (!deleteBtn || !modal) return;
28983 deleteBtn.addEventListener('click', function() {
28984 document.getElementById('delete-run-status').style.display = 'none';
28985 modal.style.display = 'flex';
28986 });
28987 cancelBtn.addEventListener('click', function() { modal.style.display = 'none'; });
28988 modal.addEventListener('click', function(e) { if (e.target === modal) modal.style.display = 'none'; });
28989 confirmBtn.addEventListener('click', async function() {
28990 confirmBtn.disabled = true;
28991 cancelBtn.disabled = true;
28992 var status = document.getElementById('delete-run-status');
28993 status.style.display = 'block';
28994 status.style.background = '#dbeafe'; status.style.color = '#1e40af';
28995 status.textContent = 'Deleting\u2026';
28996 try {
28997 var resp = await fetch('/api/runs/{{ run_id }}', { method: 'DELETE' });
28998 if (resp.status === 204 || resp.ok) {
28999 status.style.background = '#dcfce7'; status.style.color = '#166534';
29000 status.textContent = 'Deleted. Redirecting\u2026';
29001 setTimeout(function() { window.location.href = '/view-reports'; }, 1200);
29002 } else {
29003 var d = await resp.json().catch(function(){return {};});
29004 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
29005 status.textContent = 'Error: ' + (d.error || 'Unexpected server error');
29006 confirmBtn.disabled = false;
29007 cancelBtn.disabled = false;
29008 }
29009 } catch (e) {
29010 status.style.background = '#fee2e2'; status.style.color = '#991b1b';
29011 status.textContent = 'Network error: ' + String(e);
29012 confirmBtn.disabled = false;
29013 cancelBtn.disabled = false;
29014 }
29015 });
29016 })();
29017 </script>
29018 <script nonce="{{ csp_nonce }}">(function(){
29019 var bundleBtn = document.getElementById('download-bundle-btn');
29020 if (bundleBtn) {
29021 bundleBtn.addEventListener('click', function() {
29022 bundleBtn.disabled = true;
29023 var orig = bundleBtn.textContent;
29024 bundleBtn.textContent = 'Preparing\u2026';
29025 fetch('/api/runs/{{ run_id }}/bundle')
29026 .then(function(r) {
29027 if (!r.ok) throw new Error('HTTP ' + r.status);
29028 return r.blob();
29029 })
29030 .then(function(blob) {
29031 var url = URL.createObjectURL(blob);
29032 var a = document.createElement('a');
29033 a.href = url;
29034 a.download = 'oxide-sloc-{{ run_id }}.tar.gz';
29035 document.body.appendChild(a);
29036 a.click();
29037 setTimeout(function() { URL.revokeObjectURL(url); document.body.removeChild(a); }, 5000);
29038 bundleBtn.disabled = false;
29039 bundleBtn.textContent = orig;
29040 })
29041 .catch(function(e) {
29042 bundleBtn.disabled = false;
29043 bundleBtn.textContent = orig;
29044 alert('Bundle download failed: ' + String(e));
29045 });
29046 });
29047 }
29048 })();</script>
29049 <script nonce="{{ csp_nonce }}">(function(){
29050 var dot=document.getElementById('status-dot');
29051 var pingEl=document.getElementById('server-ping-ms');
29052 var tipEl=document.getElementById('server-tip-ping');
29053 var fm=document.getElementById('footer-mode');
29054 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)';}}
29055 function doPing(){
29056 var t0=performance.now();
29057 fetch('/healthz',{cache:'no-store'})
29058 .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);})
29059 .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)';}});
29060 }
29061 doPing();
29062 setInterval(doPing,5000);
29063 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');}
29064 })();</script>
29065 <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>
29066 {% if let Some(banner) = report_header_footer %}
29067 <div class="report-id-footer-banner" aria-label="Report identification">{{ banner|e }}</div>
29068 {% endif %}
29069</body>
29070</html>
29071"##,
29072 ext = "html"
29073)]
29074#[allow(clippy::struct_excessive_bools)]
29076struct ResultTemplate {
29077 version: &'static str,
29078 report_title: String,
29079 project_path: String,
29080 output_dir: String,
29081 run_id: String,
29082 files_analyzed: u64,
29083 files_skipped: u64,
29084 physical_lines: u64,
29085 code_lines: u64,
29086 comment_lines: u64,
29087 blank_lines: u64,
29088 mixed_lines: u64,
29089 functions: u64,
29090 classes: u64,
29091 variables: u64,
29092 imports: u64,
29093 html_url: Option<String>,
29094 pdf_url: Option<String>,
29095 json_url: Option<String>,
29096 html_download_url: Option<String>,
29097 pdf_download_url: Option<String>,
29098 json_download_url: Option<String>,
29099 html_path: Option<String>,
29100 json_path: Option<String>,
29101 prev_run_id: Option<String>,
29102 prev_run_timestamp: Option<String>,
29103 prev_run_code_lines: Option<u64>,
29104 prev_fa_str: String,
29106 prev_fs_str: String,
29107 prev_pl_str: String,
29108 prev_cl_str: String,
29109 prev_cml_str: String,
29110 prev_bl_str: String,
29111 delta_fa_str: String,
29113 delta_fa_class: String,
29114 delta_fs_str: String,
29115 delta_fs_class: String,
29116 delta_pl_str: String,
29117 delta_pl_class: String,
29118 delta_cl_str: String,
29119 delta_cl_class: String,
29120 delta_cml_str: String,
29121 delta_cml_class: String,
29122 delta_bl_str: String,
29123 delta_bl_class: String,
29124 delta_lines_added: Option<i64>,
29126 delta_lines_removed: Option<i64>,
29127 delta_lines_net_str: String,
29128 delta_lines_net_class: String,
29129 delta_files_added: Option<usize>,
29130 delta_files_removed: Option<usize>,
29131 delta_files_modified: Option<usize>,
29132 delta_files_unchanged: Option<usize>,
29133 delta_files_total: Option<usize>,
29134 delta_unmodified_lines: Option<u64>,
29135 git_branch: Option<String>,
29137 git_branch_url: Option<String>,
29138 git_commit: Option<String>,
29139 git_commit_long: Option<String>,
29140 git_author: Option<String>,
29141 git_commit_url: Option<String>,
29142 scan_performed_by: String,
29144 scan_time_display: String,
29145 scan_time_utc_ms: i64,
29146 os_display: String,
29147 test_count: u64,
29148 test_assertion_count: u64,
29150 prev_scan_count: usize,
29152 current_scan_number: usize,
29153 submodule_rows: Vec<SubmoduleRow>,
29155 scan_config_url: String,
29156 lang_chart_json: String,
29157 #[allow(dead_code)]
29159 scatter_chart_json: String,
29160 #[allow(dead_code)]
29161 semantic_chart_json: String,
29162 #[allow(dead_code)]
29163 submodule_chart_json: String,
29164 #[allow(dead_code)]
29165 has_submodule_data: bool,
29166 #[allow(dead_code)]
29167 has_semantic_data: bool,
29168 pdf_generating: bool,
29169 csp_nonce: String,
29170 confluence_configured: bool,
29172 server_mode: bool,
29173 report_header_footer: Option<String>,
29175 run_id_short: String,
29176 #[allow(dead_code)]
29178 is_offline: bool,
29179 cyclomatic_complexity: u64,
29181 lsloc: Option<u64>,
29183 uloc: u64,
29185 dryness_pct_str: String,
29187 duplicate_group_count: usize,
29189 has_cocomo: bool,
29191 cocomo_effort_str: String,
29193 cocomo_duration_str: String,
29195 cocomo_staff_str: String,
29197 cocomo_ksloc_str: String,
29199 cocomo_mode_label: String,
29201 cocomo_mode_tooltip: String,
29203 complexity_alert: u32,
29205 has_coverage_data: bool,
29207 cov_line_pct: String,
29209 cov_fn_pct: String,
29211 cov_branch_pct: String,
29213 cov_lines_summary: String,
29215 ownership_html: String,
29218}
29219
29220#[derive(Template)]
29221#[template(
29222 source = r##"
29223<!doctype html>
29224<html lang="en">
29225<head>
29226 <meta charset="utf-8">
29227 <meta name="viewport" content="width=device-width, initial-scale=1">
29228 <title>OxideSLOC | Analyzing…</title>
29229 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
29230 <link rel="stylesheet" href="/static/app.css">
29231 <script src="/static/app.js"></script>
29232 <style nonce="{{ csp_nonce }}">
29233 :root {
29234 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
29235 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
29236 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
29237 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
29238 }
29239 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
29240 *{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;}
29241 .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);}
29242 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
29243 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
29244 .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));}
29245 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
29246 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
29247 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;line-height:1.2;white-space:nowrap;}
29248 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
29249 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
29250 @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; } }
29251 .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;}
29252 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
29253 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
29254 .page-body{padding:32px 24px 36px;}
29255 .wait-panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:36px 40px;box-shadow:var(--shadow);position:relative;}
29256 .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;}
29257 .pulse-dot{width:9px;height:9px;border-radius:50%;background:var(--accent-2);animation:pulse 1.4s ease-in-out infinite;}
29258 @keyframes pulse{0%,100%{opacity:1;transform:scale(1);}50%{opacity:0.4;transform:scale(0.7);}}
29259 .wait-title{font-size:1.6rem;font-weight:800;color:var(--text);margin:0 0 6px;}
29260 .wait-sub{color:var(--muted);font-size:0.95rem;margin-bottom:24px;}
29261 .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;}
29262 .metrics-row{display:flex;gap:20px;margin-bottom:24px;flex-wrap:wrap;}
29263 .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;}
29264 .metric-label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px;}
29265 .metric-value{font-size:1.1rem;font-weight:700;color:var(--text);}
29266 .overall-wrap{margin-bottom:16px;}
29267 .overall-head{display:flex;align-items:baseline;justify-content:space-between;margin-bottom:6px;}
29268 .overall-label{font-size:11px;font-weight:800;color:var(--muted);text-transform:uppercase;letter-spacing:.07em;}
29269 .overall-pct{font-size:14px;font-weight:900;color:var(--oxide);font-variant-numeric:tabular-nums;}
29270 .overall-track{height:9px;border-radius:999px;background:var(--surface-2);border:1px solid var(--line);overflow:hidden;}
29271 .overall-fill{height:100%;width:0%;border-radius:999px;background:linear-gradient(90deg,var(--accent-2),var(--oxide));transition:width .4s ease;}
29272 .progress-bar-wrap{background:var(--surface-2);border-radius:999px;height:6px;overflow:hidden;margin-bottom:24px;}
29273 .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;}
29274 @keyframes indeterminate{0%{transform:translateX(-100%) scaleX(0.5);}50%{transform:translateX(0%) scaleX(0.5);}100%{transform:translateX(200%) scaleX(0.5);}}
29275 .hidden{display:none!important;}
29276 .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;}
29277 .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;}
29278 .err-panel strong{display:block;color:#8b1f1f;margin-bottom:6px;font-size:14px;}
29279 .err-panel p{margin:0;font-size:13px;color:var(--muted);}
29280 .actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:4px;}
29281 .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);}
29282 .btn-primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(185,93,51,0.4);}
29283 .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;}
29284 .btn-outline:hover{background:rgba(185,93,51,0.08);transform:translateY(-1px);}
29285 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
29286 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
29287 @keyframes wmFade{0%,100%{opacity:.07;}50%{opacity:.13;}}
29288 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
29289 .code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
29290 @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));}}
29291 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
29292 .site-footer a{color:var(--muted);}
29293 .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;}
29294 .theme-toggle svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
29295 body:not(.dark-theme) .icon-moon{display:block;}body:not(.dark-theme) .icon-sun{display:none;}
29296 body.dark-theme .icon-moon{display:none;}body.dark-theme .icon-sun{display:block;}
29297 </style>
29298</head>
29299<body>
29300 <div class="background-watermarks" aria-hidden="true">
29301 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29302 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29303 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29304 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29305 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29306 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29307 </div>
29308 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
29309 <nav class="top-nav">
29310 <div class="top-nav-inner">
29311 <a href="/" class="brand">
29312 <img src="/images/logo/logo-text.png" alt="OxideSLOC" class="brand-logo">
29313 <div class="brand-copy">
29314 <h1 class="brand-title">OxideSLOC</h1>
29315 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
29316 </div>
29317 </a>
29318 <div class="nav-right">
29319 <a class="nav-pill" href="/">Home</a>
29320 <div class="nav-dropdown">
29321 <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>
29322 <div class="nav-dropdown-menu">
29323 <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>
29324 </div>
29325 </div>
29326 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
29327 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
29328 <div class="nav-dropdown">
29329 <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>
29330 <div class="nav-dropdown-menu">
29331 <a href="/code-ownership"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>Code Ownership</a>
29332 <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>
29333 </div>
29334 </div>
29335 <div class="server-status-wrap" id="server-status-wrap">
29336 <div class="nav-pill server-online-pill" id="server-status-pill">
29337 <span class="status-dot" id="status-dot"></span>
29338 <span id="server-status-label">Server</span>
29339 <span class="sx-d60f2ef3" id="server-ping-ms" ></span>
29340 </div>
29341 <div class="server-status-tip">
29342 OxideSLOC is running — accessible on your network.
29343 <span class="sx-238af6bc" id="server-tip-ping" ></span>
29344 </div>
29345 </div>
29346 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
29347 <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>
29348 </button>
29349 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
29350 <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>
29351 <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>
29352 </button>
29353 </div>
29354 </div>
29355 </nav>
29356 <div class="page-body">
29357 <div class="wait-panel">
29358 <div class="wait-badge"><span class="pulse-dot"></span>Analysis running</div>
29359 <h2 class="wait-title">Analyzing your project…</h2>
29360 <p class="wait-sub">Scanning files, detecting languages, and counting lines — stay for a live view of the results.</p>
29361 <div class="path-block">{{ project_path }}</div>
29362 <div class="metrics-row">
29363 <div class="metric-card">
29364 <div class="metric-label">Elapsed</div>
29365 <div class="metric-value" id="elapsed">0s</div>
29366 </div>
29367 <div class="metric-card">
29368 <div class="metric-label">Phase</div>
29369 <div class="metric-value" id="phase">Starting</div>
29370 </div>
29371 <div class="metric-card hidden" id="files-card">
29372 <div class="metric-label">Files</div>
29373 <div class="metric-value" id="files-progress">0</div>
29374 </div>
29375 </div>
29376 <div class="overall-wrap">
29377 <div class="overall-head"><span class="overall-label">Overall progress</span><span class="overall-pct" id="overall-pct">0%</span></div>
29378 <div class="overall-track"><div class="overall-fill" id="overall-fill"></div></div>
29379 </div>
29380 <div class="progress-bar-wrap"><div class="progress-bar"></div></div>
29381 <div class="warn-slow hidden" id="warn-slow">
29382 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.
29383 </div>
29384 <div class="err-panel hidden" id="err-panel">
29385 <strong>Analysis failed</strong>
29386 <p id="err-msg">An unexpected error occurred. Check that the path exists and is readable.</p>
29387 </div>
29388 <div class="actions hidden" id="actions">
29389 <a href="/scan" class="btn-primary">Try Again</a>
29390 <a href="/view-reports" class="btn-outline">View Reports</a>
29391 </div>
29392 </div>
29393 </div>
29394 <script nonce="{{ csp_nonce }}">
29395 (function() {
29396 var WAIT_ID = {{ wait_id_json|safe }};
29397 var startTime = Date.now();
29398 var pollInterval = 1500;
29399 var retries = 0;
29400 var maxRetries = 5;
29401 var warnShown = false;
29402
29403 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();}
29404
29405 function elapsed() {
29406 return Math.floor((Date.now() - startTime) / 1000);
29407 }
29408
29409 function updateElapsed() {
29410 var s = elapsed();
29411 document.getElementById('elapsed').textContent = s < 60 ? s + 's' : Math.floor(s/60) + 'm ' + (s%60) + 's';
29412 }
29413
29414 function setPhase(txt) {
29415 document.getElementById('phase').textContent = txt;
29416 }
29417
29418 // Overall-progress bands weighted by real time cost (attribution dominates large repos).
29419 var PHASE_BAND = {
29420 'Starting':[0,2],'Scanning files':[2,15],'Running':[2,15],
29421 'Summarizing submodules':[15,18],'Computing metrics':[18,20],'Reading git history':[20,22],
29422 'Attributing authorship':[22,97],'Writing reports':[97,99],'Done':[100,100],'Failed':[100,100]
29423 };
29424 var lastPct = 0;
29425 function setOverall(data, forceDone) {
29426 var pct;
29427 if (forceDone) { pct = 100; }
29428 else {
29429 var phase = (data && data.phase) || 'Starting';
29430 var band = PHASE_BAND[phase] || [lastPct, lastPct];
29431 var frac = 1;
29432 if (phase === 'Scanning files' || phase === 'Running') {
29433 frac = (data.files_total > 0) ? (data.files_done / data.files_total) : 0;
29434 } else if (phase === 'Attributing authorship') {
29435 frac = (data.attrib_total > 0) ? (data.attrib_done / data.attrib_total) : 0;
29436 }
29437 pct = band[0] + (band[1] - band[0]) * Math.max(0, Math.min(1, frac));
29438 }
29439 lastPct = Math.max(lastPct, pct);
29440 var p = Math.max(0, Math.min(100, Math.round(lastPct)));
29441 var fill = document.getElementById('overall-fill');
29442 var lbl = document.getElementById('overall-pct');
29443 if (fill) fill.style.width = p + '%';
29444 if (lbl) lbl.textContent = p + '%';
29445 }
29446
29447 var elapsedTimer = setInterval(updateElapsed, 1000);
29448
29449 function poll() {
29450 fetch('/api/runs/' + encodeURIComponent(WAIT_ID) + '/status')
29451 .then(function(r) {
29452 if (!r.ok) throw new Error('HTTP ' + r.status);
29453 return r.json();
29454 })
29455 .then(function(data) {
29456 retries = 0;
29457 if (data.state === 'complete') {
29458 clearInterval(elapsedTimer);
29459 setPhase('Done');
29460 setOverall(data, true);
29461 window.location.href = '/runs/result/' + encodeURIComponent(data.run_id);
29462 } else if (data.state === 'failed') {
29463 clearInterval(elapsedTimer);
29464 setPhase('Failed');
29465 document.getElementById('err-msg').textContent = data.message || 'Analysis failed.';
29466 document.getElementById('err-panel').classList.remove('hidden');
29467 document.getElementById('actions').classList.remove('hidden');
29468 } else {
29469 // still running
29470 var s = elapsed();
29471 if (s > 90 && !warnShown) {
29472 warnShown = true;
29473 document.getElementById('warn-slow').classList.remove('hidden');
29474 }
29475 setPhase(data.phase || 'Running');
29476 // Switch the live counter to blame progress during the attribution pass so the page
29477 // shows movement instead of a frozen "files done / total" for the slowest stage.
29478 var attribActive = (data.attrib_total || 0) > 0;
29479 var curDone = attribActive ? (data.attrib_done || 0) : (data.files_done || 0);
29480 var curTotal = attribActive ? (data.attrib_total || 0) : (data.files_total || 0);
29481 if (curTotal > 0) {
29482 var card = document.getElementById('files-card');
29483 if (card) card.classList.remove('hidden');
29484 var lbl = card ? card.querySelector('.metric-label') : null;
29485 if (lbl) lbl.textContent = attribActive ? 'Blamed' : 'Files';
29486 var fp = document.getElementById('files-progress');
29487 if (fp) fp.textContent = fmt(curDone) + ' / ' + fmt(curTotal);
29488 }
29489 if (attribActive && !warnShown) {
29490 warnShown = true;
29491 var ws = document.getElementById('warn-slow');
29492 if (ws) {
29493 ws.textContent = 'Attributing authorship: running git blame on every source file across the repo and all submodules. This is the slowest stage and scales with file count — the counter above shows live progress.';
29494 ws.classList.remove('hidden');
29495 }
29496 }
29497 setOverall(data, false);
29498 setTimeout(poll, pollInterval);
29499 }
29500 })
29501 .catch(function(err) {
29502 retries++;
29503 if (retries >= maxRetries) {
29504 clearInterval(elapsedTimer);
29505 document.getElementById('err-msg').textContent = 'Lost connection to server. Reload the page to check status.';
29506 document.getElementById('err-panel').classList.remove('hidden');
29507 document.getElementById('actions').classList.remove('hidden');
29508 } else {
29509 // exponential back-off capped at 8s
29510 setTimeout(poll, Math.min(pollInterval * Math.pow(2, retries), 8000));
29511 }
29512 });
29513 }
29514
29515 setTimeout(poll, pollInterval);
29516
29517 // If the browser restores this page from bfcache (Back after viewing results),
29518 // timers may be frozen; kick off a fresh poll so we either redirect or resume.
29519 window.addEventListener("pageshow", function(e) {
29520 if (e.persisted) { setTimeout(poll, 200); }
29521 });
29522 })();
29523 </script>
29524 <footer class="site-footer">
29525 local code analysis - metrics, history and reports
29526 · <em class="footer-mode sx-e01b0d98" id="footer-mode" >oxide-sloc v{{ version }} — Mode: Local</em>
29527 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
29528 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
29529 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
29530 · <a href="/report-bug" rel="noopener">Report a Bug</a> · <a href="/api-docs" rel="noopener">REST API</a>
29531 </footer>
29532 <script nonce="{{ csp_nonce }}">
29533 (function(){
29534 var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
29535 if(s==="dark")b.classList.add("dark-theme");
29536 var tt=document.getElementById("theme-toggle");
29537 if(tt)tt.addEventListener("click",function(){var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");});
29538 })();
29539 (function spawnCodeParticles(){
29540 var c=document.getElementById('code-particles');if(!c)return;
29541 var sn = ['NUM sloc','fn analyze()','code_lines','0 mixed','blanks: NUM','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','NUM physical','files: NUM','NUM comments','cargo build','Ok(run)','Vec<GEN>','match lang','fn main()','.rs .go .py','sloc_core','render_html','NUM code','HashMap<GEN, GN2>','Option<GEN>','&str','author AUT','last_change','ratio 0.CPX','complexity CPX','tokens: NUM','?; // try','let owner','commits: NUM','#[tokio::test]','assert_eq!','PCT% cover','blame AUT','churn PCT%','HEAD~CPX','dedup PCT%','cyclomatic CPX','impl Iterator','.await','+NUM -NM2','ownership','Span<GEN>','pub struct','trait Lang','unsafe','mixed: NUM','anyhow::Result','fn detect()','PathBuf','+NUM loc','coverage PCT%','physical: NUM','ULOC NUM','dup PCT%','let mut run']; var __GT=['Author','String','Token','FileRecord','Lang','u32','usize','PathBuf','Commit','Line','Span','Metric','Record','u64','char','Utf8','Ratio','Blame']; var __AN=['nima','core','web','cli','git','lang','ci','bot','sloc'];
29542 for(var i=0;i<38;i++){(function(idx){
29543 var el=document.createElement('span');el.className='code-particle';el.textContent=sn[idx%sn.length].split('NUM').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('NM2').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('GEN').join(__GT[Math.floor(Math.random()*__GT.length)]).split('GN2').join(__GT[Math.floor(Math.random()*__GT.length)]).split('AUT').join(__AN[Math.floor(Math.random()*__AN.length)]).split('CPX').join(''+Math.floor(Math.random()*40+1)).split('PCT').join(''+Math.floor(Math.random()*44+1));
29544 var l=(Math.random()*94+2).toFixed(1),t=(Math.random()*88+6).toFixed(1);
29545 var dur=(Math.random()*10+9).toFixed(1),delay=(Math.random()*18).toFixed(1);
29546 var rot=(Math.random()*26-13).toFixed(1),op=(Math.random() * 0.108 + 0.072).toFixed(3);
29547 el.style.left=l+'%';el.style.top=t+'%';el.style.setProperty('--rot',rot+'deg');el.style.setProperty('--op',op);
29548 el.style.animationDuration=dur+'s';el.style.animationDelay='-'+delay+'s';
29549 c.appendChild(el);
29550 })(i);}
29551 })();
29552 (function randomizeWatermarks(){
29553 var wms=Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
29554 var placed=[];
29555 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;}
29556 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];}
29557 var half=Math.floor(wms.length/2);
29558 wms.forEach(function(img,i){
29559 var pos=pick(i<half),w=Math.floor(Math.random()*60+80);
29560 var rot=(Math.random()*40-20).toFixed(1),op=(Math.random()*0.08+0.05).toFixed(2);
29561 var dur=(Math.random()*6+5).toFixed(1),delay=(Math.random()*10).toFixed(1);
29562 img.style.top=pos[0].toFixed(1)+'%';img.style.left=pos[1].toFixed(1)+'%';img.style.width=w+'px';
29563 img.style.transform='rotate('+rot+'deg)';img.style.opacity=op;
29564 img.style.animation='wmFade '+dur+'s ease-in-out -'+delay+'s infinite alternate';
29565 });
29566 })();
29567 </script>
29568 <script nonce="{{ csp_nonce }}">
29569 (function(){
29570 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'}];
29571 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);});}
29572 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
29573 function init(){
29574 var btn=document.getElementById('settings-btn');if(!btn)return;
29575 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
29576 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 class="sx-dec3b281" ><div class="settings-modal-label sx-c500155b" >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>';
29577 document.body.appendChild(m);
29578 var g=document.getElementById('scheme-grid');
29579 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);});
29580 var cl=document.getElementById('settings-close');
29581 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);});})();
29582 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');});
29583 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
29584 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
29585 }
29586 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
29587 }());
29588 </script>
29589 <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]';
29590 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;}
29591 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>
29592</body>
29593</html>
29594"##,
29595 ext = "html"
29596)]
29597struct ScanWaitTemplate {
29598 version: &'static str,
29599 wait_id_json: String,
29600 project_path: String,
29601 csp_nonce: String,
29602}
29603
29604#[derive(Template)]
29605#[template(
29606 source = r##"
29607<!doctype html>
29608<html lang="en">
29609<head>
29610 <meta charset="utf-8">
29611 <meta name="viewport" content="width=device-width, initial-scale=1">
29612 <title>OxideSLOC | Error</title>
29613 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
29614 <link rel="stylesheet" href="/static/app.css">
29615 <script src="/static/app.js"></script>
29616 <style nonce="{{ csp_nonce }}">
29617 :root {
29618 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
29619 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
29620 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
29621 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
29622 }
29623 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
29624 *{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;}
29625 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
29626 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
29627 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
29628 .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);}
29629 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
29630 .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));}
29631 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
29632 .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;}
29633 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
29634 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
29635 @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; } }
29636 .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;}
29637 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
29638 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
29639 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
29640 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
29641 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
29642 .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;}
29643 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
29644 .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);}
29645 .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;}
29646 .settings-close:hover{color:var(--text);background:var(--surface-2);}
29647 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
29648 .settings-modal-body{padding:14px 16px 16px;}
29649 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
29650 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
29651 .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;}
29652 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
29653 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
29654 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
29655 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
29656 .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;}
29657 .tz-select:focus{border-color:var(--oxide);}
29658 .page{width:100%;max-width:1720px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
29659 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
29660 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
29661 h1{margin:0 0 18px;font-size:28px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
29662 .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;}
29663 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
29664 .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);}
29665 .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;}
29666 .btn-secondary:hover{background:var(--line);}
29667 .bug-report-section{margin-top:28px;padding-top:22px;border-top:1px solid var(--line);}
29668 .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;}
29669 .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;}
29670 .bug-report-trigger .br-icon{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:2;flex-shrink:0;}
29671 .bug-report-trigger .br-chevron{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;transition:transform .2s ease;margin-left:2px;}
29672 .bug-report-trigger.open .br-chevron{transform:rotate(180deg);}
29673 .bug-report-panel{display:none;flex-direction:column;gap:12px;margin-top:18px;}
29674 .bug-report-panel.open{display:flex;}
29675 .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;}
29676 .br-network-badge.online{background:#e8f5ee;color:#2a6846;}
29677 .br-network-badge.offline{background:#fff4e5;color:#9a5b00;}
29678 body.dark-theme .br-network-badge.online{background:#1a3d2b;color:#5aba8a;}
29679 body.dark-theme .br-network-badge.offline{background:#3d2a00;color:#f0a940;}
29680 .br-net-dot{width:7px;height:7px;border-radius:50%;display:inline-block;flex-shrink:0;}
29681 .br-network-badge.online .br-net-dot{background:#2a6846;}
29682 .br-network-badge.offline .br-net-dot{background:#9a5b00;}
29683 body.dark-theme .br-network-badge.online .br-net-dot{background:#5aba8a;}
29684 body.dark-theme .br-network-badge.offline .br-net-dot{background:#f0a940;}
29685 .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;}
29686 .bug-report-btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
29687 .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;}
29688 .btn-sm:hover{background:var(--line);}
29689 .btn-sm svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;}
29690 .bug-report-hint{font-size:11px;color:var(--muted);line-height:1.5;}
29691 .bug-report-hint a{color:var(--oxide);text-decoration:none;font-weight:700;}
29692 .bug-report-hint a:hover{text-decoration:underline;}
29693 .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;}
29694 .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
29695 .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;}
29696 .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;}
29697 .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:13px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
29698 @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));}}
29699 .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;}
29700 </style>
29701</head>
29702<body>
29703 <div class="background-watermarks" aria-hidden="true">
29704 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29705 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29706 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29707 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29708 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29709 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
29710 </div>
29711 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
29712 <div class="top-nav">
29713 <div class="top-nav-inner">
29714 <a class="brand" href="/">
29715 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
29716 <div class="brand-copy">
29717 <div class="brand-title">OxideSLOC</div>
29718 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
29719 </div>
29720 </a>
29721 <div class="nav-right">
29722 <a class="nav-pill" href="/">Home</a>
29723 <div class="nav-dropdown">
29724 <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>
29725 <div class="nav-dropdown-menu">
29726 <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>
29727 </div>
29728 </div>
29729 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
29730 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
29731 <div class="nav-dropdown">
29732 <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>
29733 <div class="nav-dropdown-menu">
29734 <a href="/code-ownership"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>Code Ownership</a>
29735 <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>
29736 </div>
29737 </div>
29738 <div class="server-status-wrap" id="server-status-wrap">
29739 <div class="nav-pill server-online-pill" id="server-status-pill">
29740 <span class="status-dot" id="status-dot"></span>
29741 <span id="server-status-label">Server</span>
29742 <span class="sx-d60f2ef3" id="server-ping-ms" ></span>
29743 </div>
29744 <div class="server-status-tip">
29745 OxideSLOC is running — accessible on your network.
29746 <span class="sx-238af6bc" id="server-tip-ping" ></span>
29747 </div>
29748 </div>
29749 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
29750 <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>
29751 </button>
29752 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
29753 <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>
29754 <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>
29755 </button>
29756 </div>
29757 </div>
29758 </div>
29759
29760 <div class="page">
29761 <div class="panel">
29762 <h1>Error</h1>
29763 <div class="error-box" id="error-msg-text">{{ message }}</div>
29764 <div id="br-meta" hidden
29765 data-version="{{ version }}"
29766 data-run-id="{% if let Some(rid) = run_id %}{{ rid }}{% endif %}"
29767 data-error-code="{% if let Some(code) = error_code %}{{ code }}{% endif %}"></div>
29768 <div class="actions">
29769 <a class="btn-primary" href="/scan">Back to setup</a>
29770 {% if let Some(report_url) = last_report_url %}
29771 <a class="btn-secondary" href="{{ report_url }}">{% if let Some(label) = last_report_label %}{{ label }}{% else %}View last report{% endif %}</a>
29772 {% if report_url != "/view-reports" %}<a class="btn-secondary" href="/view-reports">View Reports</a>{% endif %}
29773 {% else %}
29774 <a class="btn-secondary" href="/view-reports">View Reports</a>
29775 {% endif %}
29776 </div>
29777 <div class="bug-report-section" id="bug-report-section">
29778 <button type="button" class="bug-report-trigger" id="bug-report-trigger" aria-expanded="false" aria-controls="bug-report-panel">
29779 <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>
29780 Generate Bug Report
29781 <svg class="br-chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
29782 </button>
29783 <div class="bug-report-panel" id="bug-report-panel" role="region" aria-label="Bug report">
29784 <div class="br-network-badge" id="br-network-badge"><span class="br-net-dot"></span><span id="br-network-label">Checking…</span></div>
29785 <pre class="bug-report-pre" id="bug-report-pre">Collecting info…</pre>
29786 <div class="bug-report-btns">
29787 <button type="button" class="btn-sm" id="bug-report-copy">
29788 <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>
29789 Copy to clipboard
29790 </button>
29791 <a class="btn-sm sx-d0466aa3" id="bug-report-github-link" href="https://github.com/oxide-sloc/oxide-sloc/issues/new" target="_blank" rel="noopener noreferrer" >
29792 <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>
29793 Open GitHub Issue
29794 </a>
29795 <button type="button" class="btn-sm sx-d0466aa3" id="bug-report-save" >
29796 <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>
29797 Save as file
29798 </button>
29799 </div>
29800 <p class="bug-report-hint sx-d0466aa3" id="br-hint-online" >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>
29801 <p class="bug-report-hint sx-d0466aa3" id="br-hint-offline" ><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>
29802 </div>
29803 </div>
29804 </div>
29805 </div>
29806 <footer class="site-footer">
29807 oxide-sloc v{{ version }} — local code metrics workbench ·
29808 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
29809 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
29810 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
29811 · <a href="/report-bug" rel="noopener">Report a Bug</a> · <a href="/api-docs" rel="noopener">REST API</a>
29812 </footer>
29813 <script nonce="{{ csp_nonce }}">(function(){
29814 var meta=document.getElementById('br-meta');
29815 var pre=document.getElementById('bug-report-pre');
29816 var copyBtn=document.getElementById('bug-report-copy');
29817 var trigger=document.getElementById('bug-report-trigger');
29818 var panel=document.getElementById('bug-report-panel');
29819 var networkBadge=document.getElementById('br-network-badge');
29820 var networkLabel=document.getElementById('br-network-label');
29821 var ghLink=document.getElementById('bug-report-github-link');
29822 var saveBtn=document.getElementById('bug-report-save');
29823 var hintOnline=document.getElementById('br-hint-online');
29824 var hintOffline=document.getElementById('br-hint-offline');
29825 if(!meta||!pre)return;
29826 var ver=meta.getAttribute('data-version')||'';
29827 var runId=meta.getAttribute('data-run-id')||'';
29828 var code=meta.getAttribute('data-error-code')||'';
29829 var msgEl=document.getElementById('error-msg-text');
29830 var msg=msgEl?msgEl.textContent.trim():'';
29831 function getBrowser(){
29832 var ua=navigator.userAgent;
29833 var m=ua.match(/(Edg|OPR|Chrome|Firefox|Safari)\/(\d+)/);
29834 if(!m)return 'Unknown browser';
29835 var n={'Edg':'Edge','OPR':'Opera'}[m[1]]||m[1];
29836 return n+' '+m[2];
29837 }
29838 var lines=['oxide-sloc Bug Report','==============================',''];
29839 lines.push('App version: v'+ver);
29840 if(code)lines.push('HTTP status: '+code);
29841 if(runId)lines.push('Run ID: '+runId);
29842 lines.push('Page: '+window.location.pathname+(window.location.search||''));
29843 lines.push('Timestamp: '+new Date().toISOString());
29844 lines.push('Browser: '+getBrowser());
29845 lines.push('Viewport: '+window.innerWidth+'x'+window.innerHeight);
29846 lines.push('');
29847 lines.push('Error message:');
29848 lines.push(msg);
29849 lines.push('');
29850 lines.push('Steps to reproduce:');
29851 lines.push(' 1. ');
29852 lines.push('');
29853 lines.push('Expected behavior:');
29854 lines.push(' ');
29855 pre.textContent=lines.join('\n');
29856 function applyNetwork(online){
29857 if(networkBadge){networkBadge.style.display='inline-flex';networkBadge.className='br-network-badge '+(online?'online':'offline');}
29858 if(networkLabel)networkLabel.textContent=online?'Internet connected':'Air-gapped / offline';
29859 if(ghLink){
29860 if(online){
29861 var body=encodeURIComponent(pre.textContent+'\n\n---\n*Generated by oxide-sloc v'+ver+'*');
29862 ghLink.href='https://github.com/oxide-sloc/oxide-sloc/issues/new?title=Bug+Report&body='+body;
29863 }
29864 ghLink.style.display=online?'inline-flex':'none';
29865 }
29866 if(saveBtn)saveBtn.style.display=online?'none':'inline-flex';
29867 if(hintOnline)hintOnline.style.display=online?'block':'none';
29868 if(hintOffline)hintOffline.style.display=online?'none':'block';
29869 }
29870 applyNetwork(navigator.onLine);
29871 var probed=false;
29872 function probeNetwork(){
29873 if(probed)return;probed=true;
29874 var probeUrls=['https://github.com','https://www.google.com','https://www.cloudflare.com'];
29875 var probeIdx=0;
29876 function tryNext(){
29877 if(probeIdx>=probeUrls.length){applyNetwork(false);return;}
29878 var u=probeUrls[probeIdx++];
29879 var c2=new AbortController();
29880 var t2=setTimeout(function(){c2.abort();},4000);
29881 fetch(u,{mode:'no-cors',cache:'no-store',signal:c2.signal})
29882 .then(function(){clearTimeout(t2);applyNetwork(true);})
29883 .catch(function(){clearTimeout(t2);tryNext();});
29884 }
29885 tryNext();
29886 }
29887 if(trigger&&panel){
29888 trigger.addEventListener('click',function(){
29889 var open=panel.classList.toggle('open');
29890 trigger.classList.toggle('open',open);
29891 trigger.setAttribute('aria-expanded',open?'true':'false');
29892 if(open)probeNetwork();
29893 });
29894 }
29895 if(copyBtn){
29896 copyBtn.addEventListener('click',function(){
29897 var txt=pre.textContent;
29898 if(navigator.clipboard&&navigator.clipboard.writeText){
29899 navigator.clipboard.writeText(txt).then(function(){
29900 copyBtn.textContent='\u2713 Copied!';
29901 setTimeout(function(){copyBtn.innerHTML='<svg class="sx-78514aa8" 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> Copy to clipboard';},2000);
29902 });
29903 }else{
29904 var ta=document.createElement('textarea');
29905 ta.value=txt;ta.style.position='fixed';ta.style.opacity='0';
29906 document.body.appendChild(ta);ta.select();
29907 try{document.execCommand('copy');copyBtn.textContent='\u2713 Copied!';}catch(e){}
29908 document.body.removeChild(ta);
29909 }
29910 });
29911 }
29912 if(saveBtn){
29913 saveBtn.addEventListener('click',function(){
29914 var txt=pre.textContent;
29915 var blob=new Blob([txt],{type:'text/plain'});
29916 var url=URL.createObjectURL(blob);
29917 var a=document.createElement('a');
29918 a.href=url;a.download='oxide-sloc-bug-report-'+new Date().toISOString().slice(0,10)+'.txt';
29919 document.body.appendChild(a);a.click();
29920 document.body.removeChild(a);URL.revokeObjectURL(url);
29921 });
29922 }
29923 })();</script>
29924 <script nonce="{{ csp_nonce }}">
29925 (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");});})();
29926 (function spawnCodeParticles() {
29927 var container = document.getElementById('code-particles');
29928 if (!container) return;
29929 var snippets = ['NUM sloc','fn analyze()','code_lines','0 mixed','blanks: NUM','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','NUM physical','files: NUM','NUM comments','cargo build','Ok(run)','Vec<GEN>','match lang','fn main()','.rs .go .py','sloc_core','render_html','NUM code','HashMap<GEN, GN2>','Option<GEN>','&str','author AUT','last_change','ratio 0.CPX','complexity CPX','tokens: NUM','?; // try','let owner','commits: NUM','#[tokio::test]','assert_eq!','PCT% cover','blame AUT','churn PCT%','HEAD~CPX','dedup PCT%','cyclomatic CPX','impl Iterator','.await','+NUM -NM2','ownership','Span<GEN>','pub struct','trait Lang','unsafe','mixed: NUM','anyhow::Result','fn detect()','PathBuf','+NUM loc','coverage PCT%','physical: NUM','ULOC NUM','dup PCT%','let mut run']; var __GT=['Author','String','Token','FileRecord','Lang','u32','usize','PathBuf','Commit','Line','Span','Metric','Record','u64','char','Utf8','Ratio','Blame']; var __AN=['nima','core','web','cli','git','lang','ci','bot','sloc'];
29930 for (var i = 0; i < 44; i++) {
29931 (function(idx) {
29932 var el = document.createElement('span');
29933 el.className = 'code-particle';
29934 el.textContent = snippets[idx % snippets.length].split('NUM').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('NM2').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('GEN').join(__GT[Math.floor(Math.random()*__GT.length)]).split('GN2').join(__GT[Math.floor(Math.random()*__GT.length)]).split('AUT').join(__AN[Math.floor(Math.random()*__AN.length)]).split('CPX').join(''+Math.floor(Math.random()*40+1)).split('PCT').join(''+Math.floor(Math.random()*44+1));
29935 var left = Math.random() * 94 + 2;
29936 var top = Math.random() * 88 + 6;
29937 var dur = (Math.random() * 10 + 9).toFixed(1);
29938 var delay = (Math.random() * 18).toFixed(1);
29939 var rot = (Math.random() * 26 - 13).toFixed(1);
29940 var op = (Math.random() * 0.108 + 0.072).toFixed(3);
29941 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';
29942 container.appendChild(el);
29943 })(i);
29944 }
29945 })();
29946 (function randomizeWatermarks() {
29947 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
29948 var placed = [];
29949 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; }
29950 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]; }
29951 var half = Math.floor(wms.length/2);
29952 wms.forEach(function(img, i) {
29953 var pos = pick(i < half);
29954 var w = Math.floor(Math.random()*60+80);
29955 var rot = (Math.random()*40-20).toFixed(1);
29956 var op = (Math.random()*0.08+0.05).toFixed(2);
29957 var animDur = (Math.random()*6+5).toFixed(1);
29958 var animDelay = (Math.random()*10).toFixed(1);
29959 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';
29960 });
29961 })();
29962 </script>
29963 <script nonce="{{ csp_nonce }}">
29964 (function(){
29965 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'}];
29966 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);});}
29967 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
29968 function init(){
29969 var btn=document.getElementById('settings-btn');if(!btn)return;
29970 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
29971 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 class="sx-dec3b281" ><div class="settings-modal-label sx-c500155b" >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>';
29972 document.body.appendChild(m);
29973 var g=document.getElementById('scheme-grid');
29974 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);});
29975 var cl=document.getElementById('settings-close');
29976 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);});})();
29977 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');});
29978 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
29979 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
29980 }
29981 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
29982 }());
29983 </script>
29984 <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]';
29985 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;}
29986 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>
29987</body>
29988</html>
29989"##,
29990 ext = "html"
29991)]
29992struct ErrorTemplate {
29993 message: String,
29994 last_report_url: Option<String>,
29996 last_report_label: Option<String>,
29998 run_id: Option<String>,
30000 error_code: Option<u16>,
30002 csp_nonce: String,
30003 version: &'static str,
30004}
30005
30006#[derive(Template)]
30009#[template(
30010 source = r##"
30011<!doctype html>
30012<html lang="en">
30013<head>
30014 <meta charset="utf-8">
30015 <meta name="viewport" content="width=device-width, initial-scale=1">
30016 <title>OxideSLOC | Locate Report</title>
30017 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
30018 <link rel="stylesheet" href="/static/app.css">
30019 <script src="/static/app.js"></script>
30020 <style nonce="{{ csp_nonce }}">
30021 :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);}
30022 body.dark-theme{--bg:#1b1511;--surface:#261c17;--surface-2:#2d221d;--line:#524238;--line-strong:#6b5548;--text:#f5ece6;--muted:#c7b7aa;--muted-2:#9c877a;}
30023 *{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;}
30024 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30025 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
30026 .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);}
30027 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
30028 .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));}
30029 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
30030 .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;}
30031 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
30032 @media(max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
30033 @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;}}
30034 .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;}
30035 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
30036 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
30037 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
30038 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
30039 .theme-toggle .icon-sun{display:none;}body.dark-theme .theme-toggle .icon-sun{display:block;}body.dark-theme .theme-toggle .icon-moon{display:none;}
30040 .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;}
30041 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
30042 .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);}
30043 .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;}
30044 .settings-close:hover{color:var(--text);background:var(--surface-2);}
30045 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
30046 .settings-modal-body{padding:14px 16px 16px;}
30047 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
30048 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
30049 .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;}
30050 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
30051 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
30052 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
30053 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
30054 .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;}
30055 .tz-select:focus{border-color:var(--oxide);}
30056 .page{width:100%;max-width:1404px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
30057 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
30058 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
30059 .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 20px;line-height:1.55;}
30060 .field-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin-bottom:6px;}
30061 .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;}
30062 .filename-chip svg{flex:0 0 auto;opacity:0.6;}
30063 .locate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
30064 .locate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
30065 .locate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
30066 .locate-row{display:flex;gap:8px;align-items:stretch;}
30067 .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;}
30068 .locate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
30069 body.dark-theme .locate-input{background:var(--surface-2);}
30070 .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;}
30071 .warning-banner.show{display:flex;}
30072 .warning-banner svg{flex:0 0 auto;}
30073 body.dark-theme .warning-banner{background:#3d2800;border-color:#a06820;color:#ffcf7a;}
30074 .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;}
30075 .error-inline.show{display:flex;}
30076 .error-inline svg{flex:0 0 auto;margin-top:2px;}
30077 body.dark-theme .error-inline{background:#4a1e1e;border-color:#b85555;color:#ffb3b3;}
30078 .err-kv{border-collapse:collapse;margin:6px 0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;}
30079 .err-kv-k{padding:2px 14px 2px 0;font-weight:700;white-space:nowrap;vertical-align:top;opacity:.85;}
30080 .err-kv-v{padding:2px 0;word-break:break-all;vertical-align:top;}
30081 .err-kv-p{margin:0 0 4px;}
30082 .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;}
30083 .success-inline.show{display:flex;}
30084 body.dark-theme .success-inline{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
30085 .folder-hint-shell{border:1px solid var(--line);border-radius:14px;overflow:hidden;background:var(--surface);margin-top:20px;}
30086 .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;}
30087 body.dark-theme .folder-hint-hdr{background:linear-gradient(180deg,var(--surface-2),rgba(0,0,0,0.12));}
30088 .folder-hint-body{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;}
30089 .fh-row{display:flex;align-items:center;gap:6px;padding:7px 14px;border-bottom:1px solid rgba(0,0,0,0.04);}
30090 .fh-row:nth-child(odd){background:rgba(255,255,255,0.25);}
30091 body.dark-theme .fh-row:nth-child(odd){background:rgba(255,255,255,0.02);}
30092 .fh-row:last-child{border-bottom:none;}
30093 .fh-i1{padding-left:36px;}.fh-i2{padding-left:58px;}
30094 .fh-dir{font-weight:800;color:var(--text);}
30095 .fh-hl{color:var(--oxide);font-weight:700;}
30096 .fh-muted{color:var(--muted);}
30097 .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;}
30098 body.dark-theme .fh-badge{background:rgba(255,140,90,0.15);border-color:rgba(255,140,90,0.30);}
30099 .fh-tog{color:var(--muted-2);font-size:13px;flex:0 0 14px;}
30100 .fh-bul{color:var(--muted-2);font-size:8px;flex:0 0 14px;text-align:center;opacity:0.5;}
30101 .btn-row{margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
30102 .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;}
30103 .btn-primary:disabled{opacity:0.4;cursor:not-allowed;box-shadow:none;}
30104 .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;}
30105 .btn-secondary:hover{background:var(--line);}
30106 .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;}
30107 .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;}
30108 .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:13px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
30109 @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));}}
30110 .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;}
30111 .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;}
30112 .site-footer a{color:var(--muted);text-decoration:none;}.site-footer a:hover{color:var(--oxide);}
30113 </style>
30114</head>
30115<body>
30116 <div class="background-watermarks" aria-hidden="true">
30117 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30118 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30119 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30120 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30121 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30122 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30123 </div>
30124 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
30125 <div class="top-nav">
30126 <div class="top-nav-inner">
30127 <a class="brand" href="/">
30128 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
30129 <div class="brand-copy">
30130 <div class="brand-title">OxideSLOC</div>
30131 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
30132 </div>
30133 </a>
30134 <div class="nav-right">
30135 <a class="nav-pill" href="/">Home</a>
30136 <div class="nav-dropdown">
30137 <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>
30138 <div class="nav-dropdown-menu">
30139 <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>
30140 </div>
30141 </div>
30142 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
30143 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
30144 <div class="nav-dropdown">
30145 <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>
30146 <div class="nav-dropdown-menu">
30147 <a href="/code-ownership"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>Code Ownership</a>
30148 <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>
30149 </div>
30150 </div>
30151 <div class="server-status-wrap" id="server-status-wrap">
30152 <div class="nav-pill server-online-pill" id="server-status-pill">
30153 <span class="status-dot" id="status-dot"></span>
30154 <span id="server-status-label">Server</span>
30155 <span class="sx-d60f2ef3" id="server-ping-ms" ></span>
30156 </div>
30157 <div class="server-status-tip">
30158 OxideSLOC is running — accessible on your network.
30159 <span class="sx-238af6bc" id="server-tip-ping" ></span>
30160 </div>
30161 </div>
30162 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
30163 <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>
30164 </button>
30165 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
30166 <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>
30167 <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>
30168 </button>
30169 </div>
30170 </div>
30171 </div>
30172
30173 <div class="page">
30174 <div id="locate-meta" hidden data-expected="{{ expected_filename }}" data-run-id="{{ run_id }}" data-redirect="/runs/{{ artifact_type }}/{{ run_id }}"></div>
30175 <div class="panel">
30176 <h1>Report File Not Found</h1>
30177 <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>
30178 <div class="field-label">Missing file</div>
30179 <div class="filename-chip">
30180 <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>
30181 {{ expected_filename }}
30182 </div>
30183 <div class="locate-section">
30184 <h2>Locate Scan Output Folder</h2>
30185 <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>
30186 <p>OxideSLOC will find the correct files inside automatically.</p>
30187 <div class="locate-row">
30188 <input type="text" id="locate-file-input"
30189 placeholder="e.g. C:\Desktop\over-here\project_20260601-0029-…"
30190 class="locate-input" autocomplete="off" spellcheck="false">
30191 {% if !server_mode %}
30192 <button type="button" id="browse-locate-btn" class="btn-secondary">Browse…</button>
30193 {% endif %}
30194 </div>
30195 <div class="warning-banner" id="filename-warning">
30196 <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>
30197 <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>
30198 </div>
30199 <div class="error-inline" id="locate-error">
30200 <svg class="sx-2b752633" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" 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>
30201 <span id="locate-error-text"></span>
30202 </div>
30203 <div class="success-inline" id="locate-success">
30204 <svg class="sx-5becf0bb" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ><polyline points="20 6 9 17 4 12"/></svg>
30205 <span>Scan restored — loading report…</span>
30206 </div>
30207 <div class="btn-row">
30208 <button type="button" id="locate-submit-btn" class="btn-primary" disabled>Restore Report</button>
30209 <a class="btn-secondary" href="/view-reports">View Reports</a>
30210 </div>
30211 <div class="folder-hint-shell">
30212 <div class="folder-hint-hdr">
30213 <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>
30214 Expected Folder Structure — Select the Top-Level Folder
30215 </div>
30216 <div class="folder-hint-body">
30217 <div class="fh-row">
30218 <span class="fh-tog">►</span>
30219 <span class="fh-dir">project_20260601-0029-…/</span>
30220 <span class="fh-badge">← select this</span>
30221 </div>
30222 <div class="fh-row fh-i1">
30223 <span class="fh-tog">►</span>
30224 <span class="fh-dir">html/</span>
30225 </div>
30226 <div class="fh-row fh-i2">
30227 <span class="fh-bul">•</span>
30228 <span class="fh-hl">{{ expected_filename }}</span>
30229 </div>
30230 <div class="fh-row fh-i1">
30231 <span class="fh-tog">►</span>
30232 <span class="fh-dir">json/</span>
30233 </div>
30234 <div class="fh-row fh-i2">
30235 <span class="fh-bul">•</span>
30236 <span class="fh-muted">result_*.json</span>
30237 </div>
30238 <div class="fh-row fh-i1">
30239 <span class="fh-tog">►</span>
30240 <span class="fh-dir">pdf/</span>
30241 </div>
30242 <div class="fh-row fh-i2">
30243 <span class="fh-bul">•</span>
30244 <span class="fh-muted">report_*.pdf</span>
30245 </div>
30246 <div class="fh-row fh-i1">
30247 <span class="fh-tog">►</span>
30248 <span class="fh-dir">excel/</span>
30249 </div>
30250 <div class="fh-row fh-i2">
30251 <span class="fh-bul">•</span>
30252 <span class="fh-muted">report_*.csv report_*.xlsx</span>
30253 </div>
30254 </div>
30255 </div>
30256 </div>
30257 </div>
30258 </div>
30259 <footer class="site-footer">
30260 oxide-sloc v{{ version }} — local code metrics workbench ·
30261 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
30262 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
30263 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
30264 · <a href="/report-bug" rel="noopener">Report a Bug</a> · <a href="/api-docs" rel="noopener">REST API</a>
30265 </footer>
30266 <script nonce="{{ csp_nonce }}">(function(){
30267 var k="oxide-theme",b=document.body,s=localStorage.getItem(k);
30268 if(s==="dark")b.classList.add("dark-theme");
30269 document.getElementById("theme-toggle").addEventListener("click",function(){
30270 var d=b.classList.toggle("dark-theme");localStorage.setItem(k,d?"dark":"light");
30271 });
30272 })();</script>
30273 <script nonce="{{ csp_nonce }}">(function spawnCodeParticles(){
30274 var c=document.getElementById('code-particles');if(!c)return;
30275 var snips = ['NUM sloc','fn analyze()','code_lines','0 mixed','blanks: NUM','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','NUM physical','files: NUM','NUM comments','cargo build','Ok(run)','Vec<GEN>','match lang','fn main()','.rs .go .py','sloc_core','render_html','NUM code','HashMap<GEN, GN2>','Option<GEN>','&str','author AUT','last_change','ratio 0.CPX','complexity CPX','tokens: NUM','?; // try','let owner','commits: NUM','#[tokio::test]','assert_eq!','PCT% cover','blame AUT','churn PCT%','HEAD~CPX','dedup PCT%','cyclomatic CPX','impl Iterator','.await','+NUM -NM2','ownership','Span<GEN>','pub struct','trait Lang','unsafe','mixed: NUM','anyhow::Result','fn detect()','PathBuf','+NUM loc','coverage PCT%','physical: NUM','ULOC NUM','dup PCT%','let mut run']; var __GT=['Author','String','Token','FileRecord','Lang','u32','usize','PathBuf','Commit','Line','Span','Metric','Record','u64','char','Utf8','Ratio','Blame']; var __AN=['nima','core','web','cli','git','lang','ci','bot','sloc'];
30276 for(var i=0;i<44;i++){(function(idx){var el=document.createElement('span');el.className='code-particle';el.textContent=snips[idx%snips.length].split('NUM').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('NM2').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('GEN').join(__GT[Math.floor(Math.random()*__GT.length)]).split('GN2').join(__GT[Math.floor(Math.random()*__GT.length)]).split('AUT').join(__AN[Math.floor(Math.random()*__AN.length)]).split('CPX').join(''+Math.floor(Math.random()*40+1)).split('PCT').join(''+Math.floor(Math.random()*44+1));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.108 + 0.072).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);}
30277 })();
30278 (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>
30279 <script nonce="{{ csp_nonce }}">(function(){
30280 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'}];
30281 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);});}
30282 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
30283 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 class="sx-dec3b281" ><div class="settings-modal-label sx-c500155b" >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');});}
30284 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
30285 }());</script>
30286 <script nonce="{{ csp_nonce }}">(function(){
30287 var meta=document.getElementById('locate-meta');
30288 var inp=document.getElementById('locate-file-input');
30289 var browseBtn=document.getElementById('browse-locate-btn');
30290 var submitBtn=document.getElementById('locate-submit-btn');
30291 var warning=document.getElementById('filename-warning');
30292 var errBox=document.getElementById('locate-error');
30293 var errText=document.getElementById('locate-error-text');
30294 var okBox=document.getElementById('locate-success');
30295 var expected=meta?meta.getAttribute('data-expected'):'';
30296 var runId=meta?meta.getAttribute('data-run-id'):'';
30297 var redirectUrl=meta?meta.getAttribute('data-redirect'):'/view-reports';
30298 function basename(p){return p.replace(/\\/g,'/').split('/').pop()||'';}
30299 function showErr(msg){
30300 if(errText){
30301 errText.innerHTML='';
30302 var lines=msg.split('\n');
30303 var hasPairs=lines.some(function(l){return / : /.test(l);});
30304 if(!hasPairs){errText.textContent=msg;}
30305 else{
30306 var frag=document.createDocumentFragment();var tbl=null;
30307 lines.forEach(function(line){
30308 var m=line.match(/^(.*?) : (.*)$/);
30309 if(m){
30310 if(!tbl){tbl=document.createElement('table');tbl.className='err-kv';frag.appendChild(tbl);}
30311 var tr=document.createElement('tr');
30312 var k=document.createElement('td');k.className='err-kv-k';k.textContent=m[1].trim();
30313 var v=document.createElement('td');v.className='err-kv-v';v.textContent=m[2];
30314 tr.appendChild(k);tr.appendChild(v);tbl.appendChild(tr);
30315 } else {
30316 tbl=null;
30317 if(line.trim()){var p=document.createElement('p');p.className='err-kv-p';p.textContent=line.trim();frag.appendChild(p);}
30318 }
30319 });
30320 errText.appendChild(frag);
30321 }
30322 }
30323 if(errBox)errBox.classList.add('show');
30324 if(okBox)okBox.classList.remove('show');
30325 }
30326 function clearErr(){
30327 if(errBox)errBox.classList.remove('show');
30328 if(okBox)okBox.classList.remove('show');
30329 }
30330 function validate(){
30331 var val=inp?inp.value.trim():'';
30332 clearErr();
30333 if(!val){if(submitBtn)submitBtn.disabled=true;if(warning)warning.classList.remove('show');return;}
30334 if(submitBtn)submitBtn.disabled=false;
30335 if(warning){
30336 var name=basename(val);
30337 var looksLikeFile=name.toLowerCase().slice(-5)==='.html';
30338 if(expected&&name&&looksLikeFile&&name!==expected)warning.classList.add('show');
30339 else warning.classList.remove('show');
30340 }
30341 }
30342 if(inp){inp.addEventListener('input',validate);inp.addEventListener('keydown',function(e){if(e.key==='Enter')submitBtn&&submitBtn.click();});}
30343 if(browseBtn){
30344 browseBtn.addEventListener('click',function(){
30345 browseBtn.disabled=true;browseBtn.textContent='...';
30346 fetch('/pick-directory')
30347 .then(function(r){return r.ok?r.json():{cancelled:true};})
30348 .then(function(d){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';if(d&&d.selected_path&&inp){inp.value=d.selected_path;validate();}})
30349 .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
30350 });
30351 }
30352 if(submitBtn){
30353 submitBtn.addEventListener('click',function(){
30354 var folder=inp?inp.value.trim():'';
30355 if(!folder){showErr('Please enter or browse to the scan output folder.');return;}
30356 clearErr();
30357 submitBtn.disabled=true;submitBtn.textContent='Restoring\u2026';
30358 var body=new URLSearchParams();
30359 body.set('file_path',folder);
30360 body.set('redirect_url',redirectUrl);
30361 body.set('expected_run_id',runId);
30362 fetch('/locate-report',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
30363 .then(function(r){return r.json().catch(function(){return{ok:false,message:'Server returned an unexpected response (status '+r.status+').'}; });})
30364 .then(function(d){
30365 submitBtn.disabled=false;submitBtn.textContent='Restore Report';
30366 if(d&&d.ok){
30367 if(okBox)okBox.classList.add('show');
30368 setTimeout(function(){window.location.href=d.redirect||redirectUrl;},500);
30369 } else {
30370 showErr(d&&d.message?d.message:'Unknown error. Check that the folder contains the correct scan.');
30371 }
30372 })
30373 .catch(function(e){
30374 submitBtn.disabled=false;submitBtn.textContent='Restore Report';
30375 showErr('Network error: '+String(e));
30376 });
30377 });
30378 }
30379 })();</script>
30380 <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>
30381</body>
30382</html>
30383"##,
30384 ext = "html"
30385)]
30386struct LocateFileTemplate {
30387 run_id: String,
30388 artifact_type: String,
30389 expected_filename: String,
30390 server_mode: bool,
30391 csp_nonce: String,
30392 version: &'static str,
30393}
30394
30395#[derive(Template)]
30398#[template(
30399 source = r##"
30400<!doctype html>
30401<html lang="en">
30402<head>
30403 <meta charset="utf-8">
30404 <meta name="viewport" content="width=device-width, initial-scale=1">
30405 <title>OxideSLOC | Locate Scan Files</title>
30406 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
30407 <link rel="stylesheet" href="/static/app.css">
30408 <script src="/static/app.js"></script>
30409 <style nonce="{{ csp_nonce }}">
30410 :root {
30411 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
30412 --line:#e6d0bf; --line-strong:#dcb89f; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
30413 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#4a78ee;
30414 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
30415 }
30416 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
30417 *{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;}
30418 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30419 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
30420 @keyframes wmFade{from{opacity:var(--wm-op,0.08);}to{opacity:calc(var(--wm-op,0.08)*0.3);}}
30421 .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);}
30422 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
30423 .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));}
30424 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
30425 .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;}
30426 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
30427 @media (max-width:1400px){.nav-right{gap:6px;}.nav-pill,.nav-dropdown-btn,.theme-toggle{padding:0 10px;}}
30428 @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;}}
30429 .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;}
30430 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
30431 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
30432 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
30433 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
30434 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
30435 .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;}
30436 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
30437 .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);}
30438 .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;}
30439 .settings-close:hover{color:var(--text);background:var(--surface-2);}
30440 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
30441 .settings-modal-body{padding:14px 16px 16px;}
30442 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
30443 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
30444 .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;}
30445 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
30446 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
30447 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
30448 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
30449 .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;}
30450 .tz-select:focus{border-color:var(--oxide);}
30451 .page{max-width:1560px;margin:0 auto;padding:28px 24px 36px;position:relative;z-index:1;}
30452 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:28px;}
30453 h1{margin:0 0 6px;font-size:26px;font-weight:850;letter-spacing:-0.03em;color:var(--oxide-2);}
30454 .panel-subtitle{font-size:13px;color:var(--muted);margin:0 0 18px;}
30455 .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;}
30456 .error-box.hidden{display:none;}
30457 .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;}
30458 body.dark-theme .success-box{background:#163927;border-color:#2d7a52;color:#8fe2a8;}
30459 .actions{margin-top:18px;display:flex;gap:10px;flex-wrap:wrap;}
30460 .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;}
30461 .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;}
30462 .site-footer a{color:var(--oxide);text-decoration:none;}.site-footer a:hover{text-decoration:underline;}
30463 .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;}
30464 .btn-secondary:hover{background:var(--line);}
30465 .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;}
30466 .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;}
30467 .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:13px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
30468 @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));}}
30469 .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;}
30470 .relocate-section{border:1px solid var(--line);border-radius:14px;padding:20px 22px;background:var(--surface-2);}
30471 .relocate-section h2{margin:0 0 4px;font-size:15px;font-weight:800;color:var(--text);}
30472 .relocate-section p{margin:0 0 14px;font-size:13px;color:var(--muted);line-height:1.5;}
30473 .relocate-row{display:flex;gap:8px;align-items:stretch;}
30474 .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;}
30475 .relocate-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(111,155,255,0.15);}
30476 body.dark-theme .relocate-input{background:var(--surface-2);}
30477 </style>
30478</head>
30479<body>
30480 <div class="background-watermarks" aria-hidden="true">
30481 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30482 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30483 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30484 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30485 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30486 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30487 </div>
30488 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
30489 <div class="top-nav">
30490 <div class="top-nav-inner">
30491 <a class="brand" href="/">
30492 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo" />
30493 <div class="brand-copy">
30494 <div class="brand-title">OxideSLOC</div>
30495 <div class="brand-subtitle">local code analysis - metrics, history and reports</div>
30496 </div>
30497 </a>
30498 <div class="nav-right">
30499 <a class="nav-pill" href="/">Home</a>
30500 <div class="nav-dropdown">
30501 <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>
30502 <div class="nav-dropdown-menu">
30503 <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>
30504 </div>
30505 </div>
30506 <a class="nav-pill sx-8c38ef73" href="/compare-scans">Compare Scans</a>
30507 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
30508 <div class="nav-dropdown">
30509 <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>
30510 <div class="nav-dropdown-menu">
30511 <a href="/code-ownership"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>Code Ownership</a>
30512 <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>
30513 </div>
30514 </div>
30515 <div class="server-status-wrap" id="server-status-wrap">
30516 <div class="nav-pill server-online-pill" id="server-status-pill">
30517 <span class="status-dot" id="status-dot"></span>
30518 <span id="server-status-label">Server</span>
30519 <span class="sx-d60f2ef3" id="server-ping-ms" ></span>
30520 </div>
30521 <div class="server-status-tip">
30522 OxideSLOC is running — accessible on your network.
30523 <span class="sx-238af6bc" id="server-tip-ping" ></span>
30524 </div>
30525 </div>
30526 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
30527 <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>
30528 </button>
30529 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
30530 <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>
30531 <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>
30532 </button>
30533 </div>
30534 </div>
30535 </div>
30536
30537 <div class="page">
30538 <div class="panel">
30539 <h1>Scan Files Moved</h1>
30540 <p class="panel-subtitle">The scan output folder was moved, renamed, or deleted. Browse to its new location to restore the comparison.</p>
30541 <div class="error-box" id="relocate-error-box">{{ message }}</div>
30542 <div class="success-box" id="relocate-success-box">Scan restored — redirecting…</div>
30543 <div class="relocate-section">
30544 <h2>Locate Scan Output</h2>
30545 <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>
30546 <div class="relocate-row">
30547 <input type="text" id="relocate-folder" name="folder_path"
30548 value="{{ folder_hint }}"
30549 placeholder="Path to folder containing scan output..."
30550 class="relocate-input" autocomplete="off" spellcheck="false">
30551 {% if !server_mode %}
30552 <button type="button" id="browse-relocate-btn" class="btn-secondary">Browse…</button>
30553 {% endif %}
30554 </div>
30555 <div class="sx-b6d781cb" >
30556 <button type="button" id="restore-btn" class="btn-primary sx-87798f9f" >Restore Scan</button>
30557 </div>
30558 </div>
30559 <div class="actions">
30560 <a class="btn-secondary" href="/compare-scans">Compare Scans</a>
30561 <a class="btn-secondary" href="/view-reports">View Reports</a>
30562 </div>
30563 </div>
30564 </div>
30565 <footer class="site-footer">
30566 oxide-sloc v{{ version }} — local code metrics workbench ·
30567 Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
30568 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
30569 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
30570 · <a href="/report-bug" rel="noopener">Report a Bug</a> · <a href="/api-docs" rel="noopener">REST API</a>
30571 </footer>
30572 <script nonce="{{ csp_nonce }}">
30573 (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");});})();
30574 (function spawnCodeParticles(){var c=document.getElementById('code-particles');if(!c)return;var snips = ['NUM sloc','fn analyze()','code_lines','0 mixed','blanks: NUM','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','NUM physical','files: NUM','NUM comments','cargo build','Ok(run)','Vec<GEN>','match lang','fn main()','.rs .go .py','sloc_core','render_html','NUM code','HashMap<GEN, GN2>','Option<GEN>','&str','author AUT','last_change','ratio 0.CPX','complexity CPX','tokens: NUM','?; // try','let owner','commits: NUM','#[tokio::test]','assert_eq!','PCT% cover','blame AUT','churn PCT%','HEAD~CPX','dedup PCT%','cyclomatic CPX','impl Iterator','.await','+NUM -NM2','ownership','Span<GEN>','pub struct','trait Lang','unsafe','mixed: NUM','anyhow::Result','fn detect()','PathBuf','+NUM loc','coverage PCT%','physical: NUM','ULOC NUM','dup PCT%','let mut run']; var __GT=['Author','String','Token','FileRecord','Lang','u32','usize','PathBuf','Commit','Line','Span','Metric','Record','u64','char','Utf8','Ratio','Blame']; var __AN=['nima','core','web','cli','git','lang','ci','bot','sloc'];for(var i=0;i<44;i++){(function(idx){var el=document.createElement('span');el.className='code-particle';el.textContent=snips[idx%snips.length].split('NUM').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('NM2').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('GEN').join(__GT[Math.floor(Math.random()*__GT.length)]).split('GN2').join(__GT[Math.floor(Math.random()*__GT.length)]).split('AUT').join(__AN[Math.floor(Math.random()*__AN.length)]).split('CPX').join(''+Math.floor(Math.random()*40+1)).split('PCT').join(''+Math.floor(Math.random()*44+1));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.108 + 0.072).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);}})();
30575 (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;});})();
30576 </script>
30577 <script nonce="{{ csp_nonce }}">
30578 (function(){
30579 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'}];
30580 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);});}
30581 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
30582 function init(){
30583 var btn=document.getElementById('settings-btn');if(!btn)return;
30584 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
30585 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 class="sx-dec3b281" ><div class="settings-modal-label sx-c500155b" >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>';
30586 document.body.appendChild(m);
30587 var g=document.getElementById('scheme-grid');
30588 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);});
30589 var cl=document.getElementById('settings-close');
30590 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);});})();
30591 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');});
30592 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
30593 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
30594 }
30595 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
30596 }());
30597 (function(){
30598 var browseBtn=document.getElementById('browse-relocate-btn');
30599 if(browseBtn){
30600 browseBtn.addEventListener('click',function(){
30601 browseBtn.disabled=true;browseBtn.textContent='...';
30602 var inp=document.getElementById('relocate-folder');
30603 var hint=inp?inp.value:'';
30604 fetch('/pick-directory?kind=reports¤t='+encodeURIComponent(hint))
30605 .then(function(r){return r.ok?r.json():{cancelled:true};})
30606 .then(function(d){
30607 browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';
30608 if(d&&d.selected_path&&inp)inp.value=d.selected_path;
30609 })
30610 .catch(function(){browseBtn.disabled=false;browseBtn.textContent='Browse\u2026';});
30611 });
30612 }
30613 var restoreBtn=document.getElementById('restore-btn');
30614 var errBox=document.getElementById('relocate-error-box');
30615 var okBox=document.getElementById('relocate-success-box');
30616 if(restoreBtn){
30617 restoreBtn.addEventListener('click',function(){
30618 var inp=document.getElementById('relocate-folder');
30619 var folder=inp?inp.value.trim():'';
30620 if(!folder){if(errBox){errBox.textContent='Please enter a folder path.';errBox.classList.remove('hidden');}return;}
30621 restoreBtn.disabled=true;restoreBtn.textContent='Checking\u2026';
30622 var body=new URLSearchParams();
30623 body.set('run_id','{{ run_id }}');
30624 body.set('redirect_url','{{ redirect_url }}');
30625 body.set('folder_path',folder);
30626 fetch('/relocate-scan',{method:'POST',headers:{'Accept':'application/json','Content-Type':'application/x-www-form-urlencoded'},body:body.toString()})
30627 .then(function(r){return r.json();})
30628 .then(function(d){
30629 restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
30630 if(d&&d.ok){
30631 if(errBox)errBox.classList.add('hidden');
30632 if(okBox){okBox.style.display='block';}
30633 setTimeout(function(){window.location.href=d.redirect||'/compare-scans';},600);
30634 } else {
30635 if(errBox){errBox.textContent=d&&d.message?d.message:'Unknown error.';errBox.classList.remove('hidden');}
30636 }
30637 })
30638 .catch(function(e){
30639 restoreBtn.disabled=false;restoreBtn.textContent='Restore Scan';
30640 if(errBox){errBox.textContent='Network error: '+String(e);errBox.classList.remove('hidden');}
30641 });
30642 });
30643 }
30644 }());
30645 </script>
30646 <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]';
30647 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;}
30648 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>
30649</body>
30650</html>
30651"##,
30652 ext = "html"
30653)]
30654struct RelocateScanTemplate {
30655 message: String,
30656 run_id: String,
30657 folder_hint: String,
30658 redirect_url: String,
30659 server_mode: bool,
30660 csp_nonce: String,
30661 version: &'static str,
30662}
30663
30664#[derive(Template)]
30667#[template(
30668 source = r##"
30669<!doctype html>
30670<html lang="en">
30671<head>
30672 <meta charset="utf-8">
30673 <meta name="viewport" content="width=device-width, initial-scale=1">
30674 <title>OxideSLOC | View Reports</title>
30675 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
30676 <link rel="stylesheet" href="/static/app.css">
30677 <script src="/static/app.js"></script>
30678 <style nonce="{{ csp_nonce }}">
30679 :root {
30680 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
30681 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
30682 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
30683 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
30684 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6;
30685 }
30686 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; }
30687 *{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;}
30688 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
30689 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
30690 .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);}
30691 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
30692 .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));}
30693 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
30694 .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;}
30695 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
30696 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
30697 @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; } }
30698 .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;}
30699 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
30700 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
30701 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
30702 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
30703 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
30704 .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;}
30705 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
30706 .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);}
30707 .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;}
30708 .settings-close:hover{color:var(--text);background:var(--surface-2);}
30709 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
30710 .settings-modal-body{padding:14px 16px 16px;}
30711 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
30712 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
30713 .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;}
30714 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
30715 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
30716 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
30717 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
30718 .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;}
30719 .tz-select:focus{border-color:var(--oxide);}
30720 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
30721 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
30722 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
30723 .panel-header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
30724 .panel-header h1{margin:0;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
30725 .panel-meta{font-size:13px;color:var(--muted);}
30726 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
30727 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
30728 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
30729 .per-page-label{font-size:13px;color:var(--muted);}
30730 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;}
30731 .filter-input{min-width:180px;cursor:text;}
30732 .table-wrap{width:100%;overflow-x:auto;}
30733 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;}
30734 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;}
30735 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
30736 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
30737 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
30738 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
30739 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
30740 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
30741 tr:last-child td{border-bottom:none;}
30742 tr:hover td{background:var(--surface-2);}
30743 .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);}
30744 .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);}
30745 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
30746 .metric-num{font-weight:700;color:var(--text);}
30747 .metric-secondary{font-size:11px;color:var(--muted);margin-top:3px;}
30748 .skipped-pill{font-size:10px;font-weight:600;font-style:italic;color:var(--muted);opacity:.9;font-variant-numeric:tabular-nums;white-space:nowrap;}
30749 .git-commit-chip{cursor:help;}
30750 .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;}
30751 .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;}
30752 .btn:hover{background:var(--line);}
30753 .btn.primary{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
30754 .btn.primary:hover{opacity:.9;}
30755 .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;}
30756 .btn-back:hover{background:var(--line);}
30757 .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;}
30758 .export-btn:hover{background:var(--line);}
30759 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
30760 .actions-cell{display:flex;gap:5px;flex-wrap:wrap;align-items:center;}
30761 .no-report{color:var(--muted);font-size:11px;font-style:italic;}
30762 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
30763 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
30764 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
30765 .pagination-info{font-size:13px;color:var(--muted);}
30766 .pagination-btns{display:flex;gap:6px;}
30767 .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;}
30768 .pg-btn:hover:not(:disabled){background:var(--line);}
30769 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
30770 .pg-btn:disabled{opacity:.35;cursor:default;}
30771 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
30772 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
30773 .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);}
30774 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
30775 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
30776 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
30777 .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);}
30778 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
30779 .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
30780 .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;}
30781 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
30782 .site-footer a{color:var(--muted);}
30783 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
30784 .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%;}
30785 .locate-label{font-size:13px;color:var(--muted);white-space:nowrap;}
30786 .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;}
30787 body.dark-theme .toast-success{background:rgba(26,143,71,0.12);border-color:rgba(163,217,177,0.3);color:#6fcf97;}
30788 .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;}
30789 body.dark-theme .toast-error{background:rgba(180,30,30,0.12);border-color:rgba(245,163,163,0.3);color:#f08080;}
30790 .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;}
30791 .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;}
30792 .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:13px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
30793 @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));}}
30794 .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;}
30795 .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;}
30796 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
30797 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
30798 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
30799 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
30800 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
30801 .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;}
30802 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
30803 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
30804 .watched-chip-rm:hover{color:var(--oxide);}
30805 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
30806 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
30807 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
30808 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
30809 .rpt-btn{min-width:58px;justify-content:center;}
30810 .flex-row{display:flex;align-items:center;gap:8px;}
30811 .report-cell{overflow:visible;white-space:normal;}
30812 #history-table col:nth-child(1){width:185px;}
30813 #history-table col:nth-child(2){width:220px;}
30814 #history-table col:nth-child(3){width:100px;}
30815 #history-table col:nth-child(4){width:72px;}
30816 #history-table col:nth-child(5){width:82px;}
30817 #history-table col:nth-child(6){width:82px;}
30818 #history-table col:nth-child(7){width:65px;}
30819 #history-table col:nth-child(8){width:90px;}
30820 #history-table col:nth-child(9){width:85px;}
30821 #history-table col:nth-child(10){width:115px;}
30822 #history-table td:nth-child(2){white-space:normal;word-break:break-word;overflow:visible;}
30823 .submod-details{margin-top:6px;font-size:12px;color:var(--muted);}
30824 .submod-details summary{cursor:pointer;font-weight:600;user-select:none;list-style:none;padding:2px 0;}
30825 .submod-details summary::-webkit-details-marker{display:none;}
30826.submod-link-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:5px;}
30827 .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;}
30828 .submod-view-btn:hover{background:rgba(111,155,255,0.22);}
30829 body.dark-theme .submod-view-btn{background:rgba(111,155,255,0.14);border-color:rgba(111,155,255,0.28);color:var(--accent);}
30830 </style>
30831</head>
30832<body>
30833 <div class="background-watermarks" aria-hidden="true">
30834 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30835 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30836 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30837 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30838 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30839 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
30840 </div>
30841 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
30842 <div class="top-nav">
30843 <div class="top-nav-inner">
30844 <a class="brand" href="/">
30845 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
30846 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">View reports</div></div>
30847 </a>
30848 <div class="nav-right">
30849 <a class="nav-pill" href="/">Home</a>
30850 <div class="nav-dropdown">
30851 <a href="/view-reports" class="nav-dropdown-btn sx-8c38ef73" >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>
30852 <div class="nav-dropdown-menu">
30853 <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>
30854 </div>
30855 </div>
30856 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
30857 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
30858 <div class="nav-dropdown">
30859 <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>
30860 <div class="nav-dropdown-menu">
30861 <a href="/code-ownership"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>Code Ownership</a>
30862 <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>
30863 </div>
30864 </div>
30865 <div class="server-status-wrap" id="server-status-wrap">
30866 <div class="nav-pill server-online-pill" id="server-status-pill">
30867 <span class="status-dot" id="status-dot"></span>
30868 <span id="server-status-label">Server</span>
30869 <span class="sx-d60f2ef3" id="server-ping-ms" ></span>
30870 </div>
30871 <div class="server-status-tip">
30872 OxideSLOC is running — accessible on your network.
30873 <span class="sx-238af6bc" id="server-tip-ping" ></span>
30874 </div>
30875 </div>
30876 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
30877 <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>
30878 </button>
30879 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
30880 <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>
30881 <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>
30882 </button>
30883 </div>
30884 </div>
30885 </div>
30886
30887 <div class="page">
30888 {% if let Some(err) = browse_error %}
30889 <div class="toast-error">
30890 <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>
30891 {{ err }}
30892 </div>
30893 {% endif %}
30894 {% if linked_count > 0 %}
30895 <div class="toast-success">
30896 <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>
30897 {% if linked_count == 1 %}Report linked — it now appears{% else %}{{ linked_count }} reports linked — they now appear{% endif %} in the list below.
30898 </div>
30899 {% endif %}
30900 <div class="watched-bar">
30901 <div class="watched-bar-left">
30902 <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>
30903 <span class="watched-label">Watched Folders</span>
30904 <div class="watched-chips">
30905 {% if server_mode %}
30906 <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
30907 {% else %}
30908 {% for dir in watched_dirs %}
30909 <span class="watched-chip">
30910 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
30911 <form class="sx-043808a9" method="POST" action="/watched-dirs/remove" >
30912 <input type="hidden" name="folder_path" value="{{ dir }}">
30913 <input type="hidden" name="redirect_to" value="/view-reports">
30914 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
30915 </form>
30916 </span>
30917 {% endfor %}
30918 {% if watched_dirs.is_empty() %}
30919 <span class="watched-none">No folders watched — click Choose to add one</span>
30920 {% endif %}
30921 {% endif %}
30922 </div>
30923 </div>
30924 {% if !server_mode %}
30925 <div class="watched-bar-right">
30926 <button type="button" class="btn" id="add-watched-btn">
30927 <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>
30928 Choose
30929 </button>
30930 <form class="sx-043808a9" method="POST" action="/watched-dirs/refresh" >
30931 <input type="hidden" name="redirect_to" value="/view-reports">
30932 <button type="submit" class="btn">↻ Refresh</button>
30933 </form>
30934 </div>
30935 {% endif %}
30936 </div>
30937 <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
30938 <div class="scan-overlay-card">
30939 <div class="scan-spinner"></div>
30940 <div class="scan-overlay-text">Scanning folder…</div>
30941 <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
30942 </div>
30943 </div>
30944 <style nonce="{{ csp_nonce }}">
30945 .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);}
30946 .scan-overlay.active{display:flex;}
30947 .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;}
30948 .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;}
30949 @keyframes scanSpin{to{transform:rotate(360deg);}}
30950 .scan-overlay-text{font-size:15px;font-weight:800;color:var(--text);}
30951 .scan-overlay-sub{font-size:12px;color:var(--muted);line-height:1.5;}
30952 </style>
30953 {% if total_scans > 0 %}
30954 <div class="summary-strip">
30955 <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>
30956 <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>
30957 <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>
30958 <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>
30959 </div>
30960 {% endif %}
30961
30962 <section class="panel">
30963 <div class="panel-header">
30964 <div>
30965 <h1>View Reports</h1>
30966 <p class="panel-meta">{{ total_scans }} report(s) available. Use the View or PDF button to open a report.</p>
30967 {% if server_mode %}<p class="panel-meta sx-4db908e4" >Showing all scans from all users on this server — scan history is shared across authenticated sessions.</p>{% endif %}
30968 </div>
30969 <div class="flex-row">
30970 <button type="button" class="export-btn" id="export-csv-btn">
30971 <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>
30972 Export CSV
30973 </button>
30974 <button type="button" class="export-btn" id="export-xls-btn">
30975 <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>
30976 Export Excel
30977 </button>
30978 </div>
30979 </div>
30980
30981 {% if entries.is_empty() %}
30982 <div class="empty-state">
30983 <strong>No reports with viewable HTML yet</strong>
30984 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.
30985 </div>
30986 {% else %}
30987 <div class="filter-row">
30988 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name…">
30989 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
30990 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
30991 </div>
30992 <div class="table-wrap">
30993 <table id="history-table">
30994 <colgroup>
30995 <col><col><col><col><col><col><col><col><col><col><col>
30996 </colgroup>
30997 <thead>
30998 <tr id="history-thead">
30999 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
31000 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
31001 <th>Run ID<div class="col-resize-handle"></div></th>
31002 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
31003 <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>
31004 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
31005 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
31006 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
31007 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
31008 <th class="sortable" data-sort-col="environment" data-sort-type="str">Environment<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
31009 <th>Report<div class="col-resize-handle"></div></th>
31010 </tr>
31011 </thead>
31012 <tbody id="history-tbody">
31013 {% for entry in entries %}
31014 <tr class="history-row" data-run="{{ entry.run_id }}"
31015 data-timestamp="{{ entry.timestamp }}"
31016 data-project="{{ entry.project_label }}"
31017 data-code="{{ entry.code_lines }}" data-files="{{ entry.files_analyzed }}"
31018 data-skipped="{{ entry.files_skipped }}"
31019 data-comments="{{ entry.comment_lines }}"
31020 data-blank="{{ entry.blank_lines }}"
31021 data-physical="{{ entry.total_physical_lines }}"
31022 data-functions="{{ entry.functions }}"
31023 data-classes="{{ entry.classes }}"
31024 data-variables="{{ entry.variables }}"
31025 data-imports="{{ entry.imports }}"
31026 data-tests="{{ entry.test_count }}"
31027 data-branch="{{ entry.git_branch }}"
31028 data-commit="{{ entry.git_commit }}"
31029 data-environment="{{ entry.performed_by }}"
31030 data-has-json="{{ entry.has_json }}"
31031 data-html-url="/runs/html/{{ entry.run_id }}">
31032 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
31033 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
31034 <td><span class="run-id-chip">{{ entry.run_id_short }}</span></td>
31035 <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>
31036 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
31037 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
31038 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
31039 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="metric-secondary">—</span>{% endif %}</td>
31040 <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>
31041 <td>{% if !entry.performed_by.is_empty() %}<span class="git-chip"{% if !entry.scan_os.is_empty() %} title="OS: {{ entry.scan_os }}"{% endif %}>{{ entry.performed_by }}</span>{% else %}<span class="metric-secondary">—</span>{% endif %}</td>
31042 <td class="report-cell">
31043 <div class="actions-cell">
31044 {% 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 %}
31045 {% 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 %}
31046 </div>
31047 {% if !entry.submodule_links.is_empty() %}
31048 <details class="submod-details">
31049 <summary>↳ {{ entry.submodule_links.len() }} submodule(s)</summary>
31050 <div class="submod-link-list">
31051 {% for sub in entry.submodule_links %}
31052 <a href="{{ sub.url }}" target="_blank" rel="noopener" class="submod-view-btn">{{ sub.name }}</a>
31053 {% endfor %}
31054 </div>
31055 </details>
31056 {% endif %}
31057 </td>
31058 </tr>
31059 {% endfor %}
31060 </tbody>
31061 </table>
31062 </div>
31063 <div class="pagination">
31064 <span class="pagination-info" id="pagination-info"></span>
31065 <div class="pagination-btns" id="pagination-btns"></div>
31066 <div class="flex-row">
31067 <span class="per-page-label">Show</span>
31068 <select class="per-page" id="per-page-sel">
31069 <option value="10">10 per page</option>
31070 <option value="25" selected>25 per page</option>
31071 <option value="50">50 per page</option>
31072 <option value="100">100 per page</option>
31073 </select>
31074 <span class="per-page-label" id="page-range-label"></span>
31075 </div>
31076 </div>
31077 {% endif %}
31078 </section>
31079 </div>
31080
31081 <footer class="site-footer">
31082 local code analysis - metrics, history and reports
31083 · <em class="footer-mode sx-e01b0d98" id="footer-mode" >oxide-sloc v{{ version }} — Mode: Local</em>
31084 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
31085 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
31086 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
31087 · <a href="/report-bug" rel="noopener">Report a Bug</a> · <a href="/api-docs" rel="noopener">REST API</a>
31088 </footer>
31089
31090 <script nonce="{{ csp_nonce }}">
31091 (function () {
31092 // ── Theme ──────────────────────────────────────────────────────────────
31093 var storageKey = 'oxide-sloc-theme';
31094 var body = document.body;
31095 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
31096 var toggle = document.getElementById('theme-toggle');
31097 if (toggle) toggle.addEventListener('click', function () {
31098 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
31099 body.classList.toggle('dark-theme', next === 'dark');
31100 try { localStorage.setItem(storageKey, next); } catch(e) {}
31101 });
31102
31103 // ── State ─────────────────────────────────────────────────────────────
31104 var perPage = 25, currentPage = 1, sortCol = null, sortOrder = 'asc';
31105 var allRows = Array.prototype.slice.call(document.querySelectorAll('.history-row'));
31106 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
31107
31108 // Aggregate stats from first (most recent) row
31109 if (allRows.length) {
31110 var first = allRows[0];
31111 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();}
31112 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>':'');}
31113 setChipVal('agg-code', first.dataset.code);
31114 setChipVal('agg-files', first.dataset.files);
31115 var projects = {}; allRows.forEach(function(r){var p=r.dataset.project||'';if(p)projects[p]=true;});
31116 var pe=document.getElementById('agg-projects'); if(pe) pe.textContent=Object.keys(projects).filter(Boolean).length;
31117 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(); });
31118 }
31119
31120 // ── Branch filter population ──────────────────────────────────────────
31121 (function() {
31122 var branches = {};
31123 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
31124 var sel = document.getElementById('branch-filter');
31125 if (sel) Object.keys(branches).sort().forEach(function(b) {
31126 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
31127 });
31128 })();
31129
31130 // ── Filter ────────────────────────────────────────────────────────────
31131 function getFilteredRows() {
31132 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
31133 var branch = ((document.getElementById('branch-filter') || {}).value || '');
31134 return Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).filter(function(r) {
31135 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
31136 if (branch && (r.dataset.branch || '') !== branch) return false;
31137 return true;
31138 });
31139 }
31140
31141 // ── Pagination ────────────────────────────────────────────────────────
31142 function renderPage() {
31143 var filtered = getFilteredRows();
31144 var total = filtered.length;
31145 var totalPages = Math.max(1, Math.ceil(total / perPage));
31146 currentPage = Math.min(currentPage, totalPages);
31147 var start = (currentPage - 1) * perPage;
31148 var end = Math.min(start + perPage, total);
31149 var shown = {};
31150 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
31151 Array.prototype.slice.call(document.querySelectorAll('#history-tbody .history-row')).forEach(function(r) {
31152 r.style.display = shown[r.dataset.run] ? '' : 'none';
31153 });
31154 var rl = document.getElementById('page-range-label');
31155 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
31156 var info = document.getElementById('pagination-info');
31157 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
31158 var btns = document.getElementById('pagination-btns');
31159 if (!btns) return;
31160 btns.innerHTML = '';
31161 function makeBtn(lbl, pg, active, disabled) {
31162 var b = document.createElement('button');
31163 b.className = 'pg-btn' + (active ? ' active' : '');
31164 b.textContent = lbl; b.disabled = disabled;
31165 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
31166 return b;
31167 }
31168 btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
31169 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
31170 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
31171 btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
31172 }
31173
31174 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
31175 window.applyFilters = function() { currentPage = 1; renderPage(); };
31176
31177 // ── Sorting ───────────────────────────────────────────────────────────
31178 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#history-thead .sortable'));
31179 function doSort(col, type, order) {
31180 var tbody = document.getElementById('history-tbody');
31181 if (!tbody) return;
31182 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
31183 rows.sort(function(a, b) {
31184 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
31185 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
31186 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
31187 return va < vb ? 1 : va > vb ? -1 : 0;
31188 });
31189 rows.forEach(function(r) { tbody.appendChild(r); });
31190 currentPage = 1; renderPage();
31191 }
31192 sortHeaders.forEach(function(th) {
31193 th.addEventListener('click', function(e) {
31194 if (e.target.classList.contains('col-resize-handle')) return;
31195 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
31196 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
31197 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
31198 th.classList.add('sort-' + sortOrder);
31199 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
31200 doSort(col, type, sortOrder);
31201 });
31202 });
31203
31204 // ── Column resize ─────────────────────────────────────────────────────
31205 (function() {
31206 var table = document.getElementById('history-table');
31207 if (!table) return;
31208 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
31209 var ths = Array.prototype.slice.call(table.querySelectorAll('#history-thead th'));
31210 ths.forEach(function(th, i) {
31211 var handle = th.querySelector('.col-resize-handle');
31212 if (!handle || !cols[i]) return;
31213 var startX, startW;
31214 handle.addEventListener('mousedown', function(e) {
31215 e.stopPropagation(); e.preventDefault();
31216 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
31217 handle.classList.add('dragging');
31218 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
31219 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
31220 document.addEventListener('mousemove', onMove);
31221 document.addEventListener('mouseup', onUp);
31222 });
31223 });
31224 })();
31225
31226 // ── Full-commit hover tooltip ─────────────────────────────────────────
31227 // The commit chips live inside an overflow:auto table wrapper, which would
31228 // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
31229 // (escaping the scroll container) and follow the cursor. Event delegation
31230 // keeps it working after pagination/sorting re-renders the rows.
31231 (function() {
31232 var tip = document.createElement('div');
31233 tip.className = 'commit-tip';
31234 tip.setAttribute('role', 'tooltip');
31235 document.body.appendChild(tip);
31236 var shown = false;
31237 function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
31238 function place(e) {
31239 var pad = 14, r = tip.getBoundingClientRect();
31240 var x = e.clientX + pad, y = e.clientY + pad;
31241 if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
31242 if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
31243 tip.style.left = x + 'px'; tip.style.top = y + 'px';
31244 }
31245 function hide() { tip.style.display = 'none'; shown = false; }
31246 document.addEventListener('mouseover', function(e) {
31247 var chip = chipFrom(e.target);
31248 if (!chip) return;
31249 var full = chip.getAttribute('data-full-commit');
31250 if (!full) return;
31251 tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
31252 });
31253 document.addEventListener('mousemove', function(e) {
31254 if (!shown) return;
31255 if (chipFrom(e.target)) place(e); else hide();
31256 });
31257 document.addEventListener('mouseout', function(e) {
31258 if (chipFrom(e.target)) hide();
31259 });
31260 })();
31261
31262 // ── Reset view ────────────────────────────────────────────────────────
31263 window.resetView = function() {
31264 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
31265 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
31266 sortCol = null; sortOrder = 'asc';
31267 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
31268 var tbody = document.getElementById('history-tbody');
31269 if (tbody) {
31270 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.history-row'));
31271 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
31272 rows.forEach(function(r) { tbody.appendChild(r); });
31273 }
31274 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
31275 var table = document.getElementById('history-table');
31276 if (table) Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; });
31277 currentPage = 1; renderPage();
31278 };
31279
31280 renderPage();
31281
31282 // ── Export helpers ────────────────────────────────────────────────────
31283 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
31284 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
31285 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);}
31286 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;');}
31287 function slocXlsx(fname,sheet,hdrs,rows){
31288 var enc=new TextEncoder();
31289 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;}
31290 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;}
31291 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
31292 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
31293 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
31294 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;}
31295 function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
31296 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];}
31297 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
31298 // Style 0=normal, 1=header(orange fill/white bold), 2=number(#,##0 right-aligned), 3=text(@)
31299 var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
31300 +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
31301 +'<fonts count="2">'
31302 +'<font><sz val="11"/><name val="Calibri"/></font>'
31303 +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
31304 +'</fonts>'
31305 +'<fills count="3">'
31306 +'<fill><patternFill patternType="none"/></fill>'
31307 +'<fill><patternFill patternType="gray125"/></fill>'
31308 +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
31309 +'</fills>'
31310 +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
31311 +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
31312 +'<cellXfs count="4">'
31313 +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
31314 +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
31315 +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
31316 +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
31317 +'</cellXfs>'
31318 +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
31319 +'</styleSheet>';
31320 var rx='<row r="1">';
31321 hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
31322 rx+='</row>';
31323 rows.forEach(function(row,ri){
31324 var rn=ri+2;rx+='<row r="'+rn+'">';
31325 row.forEach(function(cell,c){
31326 var ref=colRef(c,rn),sv=String(cell==null?'':cell);
31327 var isNum=sv!==''&&!isNaN(Number(sv))&&isFinite(Number(sv))&&/^[+\-]?\d/.test(sv);
31328 var isPct=!isNum&&/^\d+\.?\d*%$/.test(sv);
31329 if(isNum){rx+='<c r="'+ref+'" s="2"><v>'+xe(sv)+'</v></c>';}
31330 else if(isPct){rx+='<c r="'+ref+'" t="s" s="3"><v>'+S(sv)+'</v></c>';}
31331 else{rx+='<c r="'+ref+'" t="s"><v>'+S(sv)+'</v></c>';}
31332 });
31333 rx+='</row>';
31334 });
31335 var lastCol=hdrs.length,lastRow=rows.length+1;
31336 var tableRef='A1:'+colNm(lastCol)+lastRow;
31337 var tableXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
31338 +'<table xmlns="'+sns+'" id="1" name="ScanHistory" displayName="ScanHistory" ref="'+tableRef+'" totalsRowShown="0">'
31339 +'<autoFilter ref="'+tableRef+'"/>'
31340 +'<tableColumns count="'+lastCol+'">'
31341 +hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
31342 +'</tableColumns>'
31343 +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
31344 +'</table>';
31345 var wsRels='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
31346 +'<Relationships xmlns="'+pns+'relationships">'
31347 +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table1.xml"/>'
31348 +'</Relationships>';
31349 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>';
31350 var sh='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
31351 +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
31352 +'<sheetFormatPr defaultRowHeight="15"/><sheetData>'+rx+'</sheetData>'
31353 +'<tableParts count="1"><tablePart r:id="rId1"/></tableParts>'
31354 +'</worksheet>';
31355 var F={
31356 '[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>',
31357 '_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>',
31358 '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>',
31359 '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>',
31360 'xl/styles.xml':stl,
31361 'xl/sharedStrings.xml':ssXml,
31362 'xl/worksheets/sheet1.xml':sh,
31363 'xl/worksheets/_rels/sheet1.xml.rels':wsRels,
31364 'xl/tables/table1.xml':tableXml
31365 };
31366 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'];
31367 var zparts=[],zcds=[],zoff=0,znf=0;
31368 order.forEach(function(name){
31369 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
31370 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]);
31371 var entry=new Uint8Array(lha.length+nb.length+sz);
31372 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
31373 zparts.push(entry);
31374 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));
31375 var cde=new Uint8Array(cda.length+nb.length);
31376 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
31377 zcds.push(cde);zoff+=entry.length;znf++;
31378 });
31379 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
31380 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]);
31381 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
31382 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
31383 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
31384 zout.set(new Uint8Array(ea),zpos);
31385 slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
31386 }
31387
31388 // Multi-sheet XLSX builder for the scan-history export.
31389 // Styles: 0=normal 1=col-header(orange/white bold) 2=number(right) 3=section 4=bold-label 5=number(left) 6=text(@)
31390 function slocXlsxMulti(fname,sheets){
31391 var enc=new TextEncoder();
31392 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;}
31393 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;}
31394 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
31395 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
31396 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
31397 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];}
31398 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;}
31399 function colNm(n){var s='';while(n>0){n--;s=String.fromCharCode(65+(n%26))+s;n=Math.floor(n/26);}return s;}
31400 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
31401 var stl='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="'+sns+'">'
31402 +'<numFmts count="1"><numFmt numFmtId="164" formatCode="#,##0"/></numFmts>'
31403 +'<fonts count="3">'
31404 +'<font><sz val="11"/><name val="Calibri"/></font>'
31405 +'<font><sz val="11"/><b/><color rgb="FFFFFFFF"/><name val="Calibri"/></font>'
31406 +'<font><sz val="11"/><b/><color rgb="FFC45C10"/><name val="Calibri"/></font>'
31407 +'</fonts>'
31408 +'<fills count="4">'
31409 +'<fill><patternFill patternType="none"/></fill>'
31410 +'<fill><patternFill patternType="gray125"/></fill>'
31411 +'<fill><patternFill patternType="solid"><fgColor rgb="FFC45C10"/><bgColor indexed="64"/></patternFill></fill>'
31412 +'<fill><patternFill patternType="solid"><fgColor rgb="FFFAF0E6"/><bgColor indexed="64"/></patternFill></fill>'
31413 +'</fills>'
31414 +'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
31415 +'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
31416 +'<cellXfs count="7">'
31417 +'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>'
31418 +'<xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
31419 +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="right"/></xf>'
31420 +'<xf numFmtId="0" fontId="2" fillId="3" borderId="0" xfId="0" applyFont="1" applyFill="1"/>'
31421 +'<xf numFmtId="0" fontId="2" fillId="0" borderId="0" xfId="0" applyFont="1"/>'
31422 +'<xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="left"/></xf>'
31423 +'<xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>'
31424 +'</cellXfs>'
31425 +'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
31426 +'</styleSheet>';
31427 var wsXmls=[],tableCounter=0,tableXmls={},wsRelsXmls={};
31428 sheets.forEach(function(sh,sheetIdx){
31429 var rx='<row r="1">';
31430 sh.hdrs.forEach(function(h,c){rx+='<c r="'+colRef(c,1)+'" t="s" s="1"><v>'+S(h)+'</v></c>';});
31431 rx+='</row>';
31432 var rn=2;
31433 sh.rows.forEach(function(row){
31434 if(!row||row.length===0){rx+='<row r="'+rn+'"/>';rn++;return;}
31435 if(row.length===1&&row[0]&&typeof row[0]==='object'&&row[0]._sec){
31436 rx+='<row r="'+rn+'">';
31437 rx+='<c r="'+colRef(0,rn)+'" t="s" s="3"><v>'+S(row[0].v)+'</v></c>';
31438 for(var ec=1;ec<sh.hdrs.length;ec++){rx+='<c r="'+colRef(ec,rn)+'" s="3"/>';}
31439 rx+='</row>';rn++;return;
31440 }
31441 rx+='<row r="'+rn+'">';
31442 row.forEach(function(cell,c){
31443 var ref=colRef(c,rn);
31444 if(cell===null||cell===undefined||cell===''){rx+='<c r="'+ref+'"/>';return;}
31445 if(typeof cell==='object'&&cell!==null){
31446 var cv=cell.v,cs=cell.s!=null?cell.s:0;
31447 if(typeof cv==='number'){rx+='<c r="'+ref+'" s="'+cs+'"><v>'+xe(cv)+'</v></c>';}
31448 else{rx+='<c r="'+ref+'" t="s" s="'+cs+'"><v>'+S(cv)+'</v></c>';}
31449 return;
31450 }
31451 if(typeof cell==='number'){rx+='<c r="'+ref+'" s="2"><v>'+xe(cell)+'</v></c>';return;}
31452 rx+='<c r="'+ref+'" t="s"><v>'+S(cell)+'</v></c>';
31453 });
31454 rx+='</row>';rn++;
31455 });
31456 var cw='';
31457 if(sh.colWidths&&sh.colWidths.length>0){
31458 cw='<cols>';
31459 sh.colWidths.forEach(function(w,i){cw+='<col min="'+(i+1)+'" max="'+(i+1)+'" width="'+w+'" customWidth="1"/>';});
31460 cw+='</cols>';
31461 }
31462 var tblParts='';
31463 if(!sh.isKv&&sh.hdrs.length>0&&sh.rows.length>0){
31464 tableCounter++;
31465 var tc=tableCounter,colCount=sh.hdrs.length,rowCount=sh.rows.length+1;
31466 var tRef='A1:'+colNm(colCount)+rowCount;
31467 tableXmls['xl/tables/table'+tc+'.xml']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
31468 +'<table xmlns="'+sns+'" id="'+tc+'" name="Table'+tc+'" displayName="Table'+tc+'" ref="'+tRef+'" totalsRowShown="0">'
31469 +'<autoFilter ref="'+tRef+'"/>'
31470 +'<tableColumns count="'+colCount+'">'
31471 +sh.hdrs.map(function(h,i){return'<tableColumn id="'+(i+1)+'" name="'+xe(h)+'"/>';}).join('')
31472 +'</tableColumns>'
31473 +'<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>'
31474 +'</table>';
31475 wsRelsXmls['xl/worksheets/_rels/sheet'+(sheetIdx+1)+'.xml.rels']='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
31476 +'<Relationships xmlns="'+pns+'relationships">'
31477 +'<Relationship Id="rId1" Type="'+ons+'relationships/table" Target="../tables/table'+tc+'.xml"/>'
31478 +'</Relationships>';
31479 tblParts='<tableParts count="1"><tablePart r:id="rId1"/></tableParts>';
31480 }
31481 wsXmls.push('<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="'+sns+'" xmlns:r="'+ons+'relationships">'
31482 +'<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>'
31483 +'<sheetFormatPr defaultRowHeight="15"/>'+cw+'<sheetData>'+rx+'</sheetData>'+tblParts+'</worksheet>');
31484 });
31485 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>';
31486 var ctOver=sheets.map(function(_,i){return'<Override PartName="/xl/worksheets/sheet'+(i+1)+'.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';}).join('');
31487 var ctTable=Object.keys(tableXmls).map(function(k){return'<Override PartName="/'+k+'" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/>';}).join('');
31488 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>';
31489 var wbSh=sheets.map(function(sh,i){return'<sheet name="'+xe(sh.name)+'" sheetId="'+(i+1)+'" r:id="rId'+(i+1)+'"/>';}).join('');
31490 var wbXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="'+sns+'" xmlns:r="'+ons+'relationships"><sheets>'+wbSh+'</sheets></workbook>';
31491 var wbR=sheets.map(function(_,i){return'<Relationship Id="rId'+(i+1)+'" Type="'+ons+'relationships/worksheet" Target="worksheets/sheet'+(i+1)+'.xml"/>';}).join('');
31492 wbR+='<Relationship Id="rId'+(sheets.length+1)+'" Type="'+ons+'relationships/styles" Target="styles.xml"/>'
31493 +'<Relationship Id="rId'+(sheets.length+2)+'" Type="'+ons+'relationships/sharedStrings" Target="sharedStrings.xml"/>';
31494 var wbRXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="'+pns+'relationships">'+wbR+'</Relationships>';
31495 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};
31496 var order=['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels','xl/styles.xml','xl/sharedStrings.xml'];
31497 sheets.forEach(function(_,i){var k='xl/worksheets/sheet'+(i+1)+'.xml';F[k]=wsXmls[i];order.push(k);});
31498 Object.keys(wsRelsXmls).forEach(function(k){F[k]=wsRelsXmls[k];order.push(k);});
31499 Object.keys(tableXmls).forEach(function(k){F[k]=tableXmls[k];order.push(k);});
31500 var zparts=[],zcds=[],zoff=0,znf=0;
31501 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++;});
31502 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
31503 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]);
31504 var tot=zoff+cdSz+ea.length,zout=new Uint8Array(tot),zpos=0;
31505 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
31506 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
31507 zout.set(new Uint8Array(ea),zpos);
31508 slocDownload(zout,fname,'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
31509 }
31510
31511 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'};
31512 function langName(k){return LANG_NAMES[k]||String(k||'').replace(/_/g,' ')||'(unknown)';}
31513
31514 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','Environment'];
31515 function getHistoryRows(){
31516 var r=[];
31517 document.querySelectorAll('#history-tbody .history-row').forEach(function(tr){
31518 var code=Number(tr.getAttribute('data-code'))||0;
31519 var phys=Number(tr.getAttribute('data-physical'))||0;
31520 var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
31521 r.push([
31522 tr.getAttribute('data-timestamp')||'',
31523 tr.getAttribute('data-project')||'',
31524 tr.getAttribute('data-run')||'',
31525 tr.getAttribute('data-physical')||'',
31526 tr.getAttribute('data-code')||'',
31527 tr.getAttribute('data-comments')||'',
31528 tr.getAttribute('data-blank')||'',
31529 tr.getAttribute('data-files')||'',
31530 tr.getAttribute('data-skipped')||'',
31531 tr.getAttribute('data-functions')||'',
31532 tr.getAttribute('data-classes')||'',
31533 tr.getAttribute('data-variables')||'',
31534 tr.getAttribute('data-imports')||'',
31535 tr.getAttribute('data-tests')||'',
31536 dens,
31537 tr.getAttribute('data-branch')||'',
31538 tr.getAttribute('data-commit')||'',
31539 tr.getAttribute('data-environment')||''
31540 ]);
31541 });
31542 return r;
31543 }
31544 window.exportHistoryCsv = function(){slocCsv('scan-history.csv',_hh,getHistoryRows());};
31545 window.exportHistoryXls = function(){
31546 var histRows=getHistoryRows();
31547 function toN(v){var n=Number(v);return isNaN(n)||v===''?0:n;}
31548 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],r[17]];});
31549 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,20]};
31550 var jsonRow=document.querySelector('#history-tbody .history-row[data-has-json="true"]');
31551 if(!jsonRow){slocXlsxMulti('scan-history.xlsx',[histSheet]);return;}
31552 var runId=jsonRow.getAttribute('data-run')||'';
31553 var proj=(jsonRow.getAttribute('data-project')||'Latest').substring(0,18);
31554 function sn(suffix){var p=proj.substring(0,Math.max(1,28-suffix.length));return p+' - '+suffix;}
31555 fetch('/runs/json/'+runId)
31556 .then(function(r){if(!r.ok)throw new Error('no json');return r.json();})
31557 .then(function(run){
31558 var tot=run.summary_totals||{};
31559 var phys=Number(tot.total_physical_lines)||0,code=Number(tot.code_lines)||0;
31560 var dens=phys>0?(code/phys*100).toFixed(1)+'%':'0%';
31561 function B(v){return{v:v,s:4};}
31562 function N(v){return{v:typeof v==='number'?v:Number(v),s:5};}
31563 var sumRows=[
31564 [{_sec:true,v:'RUN INFORMATION'}],
31565 [B('Run ID'),(run.tool&&run.tool.run_id)||''],
31566 [B('Timestamp'),(run.tool&&run.tool.timestamp_utc)||''],
31567 [B('Project'),(run.effective_configuration&&run.effective_configuration.reporting&&run.effective_configuration.reporting.report_title)||proj],
31568 [B('Branch'),run.git_branch||''],
31569 [B('Commit'),run.git_commit_long||run.git_commit_short||''],
31570 [B('OS'),(run.environment&&(run.environment.operating_system+' / '+run.environment.architecture))||''],
31571 [B('Files Analyzed'),N(tot.files_analyzed)],
31572 [B('Files Skipped'),N(tot.files_skipped)],
31573 [],
31574 [{_sec:true,v:'CODE METRICS'}],
31575 [B('Physical Lines'),N(phys)],
31576 [B('Code Lines'),N(code)],
31577 [B('Comments'),N(tot.comment_lines)],
31578 [B('Blank Lines'),N(tot.blank_lines)],
31579 [B('Mixed Separate'),N(tot.mixed_lines_separate)],
31580 [B('Functions'),N(tot.functions)],
31581 [B('Classes / Types'),N(tot.classes)],
31582 [B('Variables'),N(tot.variables)],
31583 [B('Imports'),N(tot.imports)],
31584 [B('Tests'),N(tot.test_count)],
31585 [B('Assertions'),N(tot.test_assertion_count)],
31586 [B('Test Suites'),N(tot.test_suite_count)],
31587 [B('Code Density'),{v:dens,s:6}],
31588 [B('Tool Version'),'oxide-sloc '+((run.tool&&run.tool.version)||'')],
31589 ];
31590 var langHdrs=['Language','Files','Physical Lines','Code Lines','Code Density','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Test Suites'];
31591 var langRows=(run.totals_by_language||[]).map(function(l){
31592 var lp=Number(l.total_physical_lines)||0,lc=Number(l.code_lines)||0;
31593 var ld=lp>0?(lc/lp*100).toFixed(1)+'%':'0%';
31594 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];
31595 });
31596 var pfHdrs=['File','Language','Physical Lines','Code Lines','Comments','Blank','Functions','Classes','Variables','Imports','Tests','Assertions','Size (bytes)'];
31597 var pfRows=(run.per_file_records||[]).map(function(r){
31598 var rc=r.raw_line_categories||{},ec=r.effective_counts||{};
31599 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];
31600 });
31601 var skHdrs=['File','Status','Size (bytes)'];
31602 var skRows=(run.skipped_file_records||[]).map(function(r){
31603 return [r.relative_path,String(r.status||'').replace(/_/g,' '),r.size_bytes||0];
31604 });
31605 slocXlsxMulti('scan-history.xlsx',[
31606 histSheet,
31607 {name:sn('Summary'),hdrs:['Field / Metric','Value'],rows:sumRows,colWidths:[22,44],isKv:true},
31608 {name:sn('Languages'),hdrs:langHdrs,rows:langRows,colWidths:[16,7,14,12,13,12,10,11,10,10,10,8,11,12]},
31609 {name:sn('Per-File'),hdrs:pfHdrs,rows:pfRows,colWidths:[48,12,14,12,12,10,11,10,10,10,8,11,12]},
31610 {name:sn('Skipped'),hdrs:skHdrs,rows:skRows,colWidths:[52,24,12]}
31611 ]);
31612 })
31613 .catch(function(){slocXlsxMulti('scan-history.xlsx',[histSheet]);});
31614 };
31615
31616 var csvBtn = document.getElementById('export-csv-btn');
31617 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportHistoryCsv(); });
31618 var xlsBtn = document.getElementById('export-xls-btn');
31619 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportHistoryXls(); });
31620
31621 // ── Remaining CSP-safe event bindings ────────────────────────────────
31622 (function wireEvents() {
31623 var el;
31624 el = document.getElementById('reset-view-btn');
31625 if (el) el.addEventListener('click', window.resetView);
31626 el = document.getElementById('project-filter');
31627 if (el) el.addEventListener('input', window.applyFilters);
31628 el = document.getElementById('branch-filter');
31629 if (el) el.addEventListener('change', window.applyFilters);
31630 el = document.getElementById('per-page-sel');
31631 if (el) el.addEventListener('change', function() { window.setPerPage(this.value); });
31632 (function(){
31633 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');};
31634 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);
31635 })();
31636 el = document.getElementById('add-watched-btn');
31637 if (el) el.addEventListener('click', function() {
31638 fetch('/pick-directory?kind=reports')
31639 .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
31640 .then(function(data) {
31641 if (!data.cancelled && data.selected_path) {
31642 var form = document.createElement('form');
31643 form.method = 'POST';
31644 form.action = '/watched-dirs/add';
31645 var ri = document.createElement('input');
31646 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
31647 var fi = document.createElement('input');
31648 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
31649 form.appendChild(ri); form.appendChild(fi);
31650 document.body.appendChild(form);
31651 if (window.__scanOverlay) window.__scanOverlay();
31652 form.submit();
31653 }
31654 })
31655 .catch(function(e) { alert('Could not open folder picker: ' + e); });
31656 });
31657 })();
31658
31659 (function randomizeWatermarks() {
31660 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
31661 if (!wms.length) return;
31662 var placed = [];
31663 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;}
31664 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];}
31665 var half=Math.floor(wms.length/2);
31666 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;});
31667 })();
31668
31669 (function spawnCodeParticles() {
31670 var container = document.getElementById('code-particles');
31671 if (!container) return;
31672 var snippets = ['NUM sloc','fn analyze()','code_lines','0 mixed','blanks: NUM','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','NUM physical','files: NUM','NUM comments','cargo build','Ok(run)','Vec<GEN>','match lang','fn main()','.rs .go .py','sloc_core','render_html','NUM code','HashMap<GEN, GN2>','Option<GEN>','&str','author AUT','last_change','ratio 0.CPX','complexity CPX','tokens: NUM','?; // try','let owner','commits: NUM','#[tokio::test]','assert_eq!','PCT% cover','blame AUT','churn PCT%','HEAD~CPX','dedup PCT%','cyclomatic CPX','impl Iterator','.await','+NUM -NM2','ownership','Span<GEN>','pub struct','trait Lang','unsafe','mixed: NUM','anyhow::Result','fn detect()','PathBuf','+NUM loc','coverage PCT%','physical: NUM','ULOC NUM','dup PCT%','let mut run']; var __GT=['Author','String','Token','FileRecord','Lang','u32','usize','PathBuf','Commit','Line','Span','Metric','Record','u64','char','Utf8','Ratio','Blame']; var __AN=['nima','core','web','cli','git','lang','ci','bot','sloc'];
31673 for (var i = 0; i < 44; i++) {
31674 (function(idx) {
31675 var el = document.createElement('span');
31676 el.className = 'code-particle';
31677 el.textContent = snippets[idx % snippets.length].split('NUM').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('NM2').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('GEN').join(__GT[Math.floor(Math.random()*__GT.length)]).split('GN2').join(__GT[Math.floor(Math.random()*__GT.length)]).split('AUT').join(__AN[Math.floor(Math.random()*__AN.length)]).split('CPX').join(''+Math.floor(Math.random()*40+1)).split('PCT').join(''+Math.floor(Math.random()*44+1));
31678 var left = Math.random() * 94 + 2;
31679 var top = Math.random() * 88 + 6;
31680 var dur = (Math.random() * 10 + 9).toFixed(1);
31681 var delay = (Math.random() * 18).toFixed(1);
31682 var rot = (Math.random() * 26 - 13).toFixed(1);
31683 var op = (Math.random() * 0.108 + 0.072).toFixed(3);
31684 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';
31685 container.appendChild(el);
31686 })(i);
31687 }
31688 })();
31689 })();
31690 </script>
31691 <script nonce="{{ csp_nonce }}">
31692 (function(){
31693 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'}];
31694 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);});}
31695 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
31696 function init(){
31697 var btn=document.getElementById('settings-btn');if(!btn)return;
31698 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
31699 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 class="sx-dec3b281" ><div class="settings-modal-label sx-c500155b" >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>';
31700 document.body.appendChild(m);
31701 var g=document.getElementById('scheme-grid');
31702 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);});
31703 var cl=document.getElementById('settings-close');
31704 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);});})();
31705 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');});
31706 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
31707 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
31708 }
31709 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
31710 }());
31711 </script>
31712 <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>
31713</body>
31714</html>
31715"##,
31716 ext = "html"
31717)]
31718struct HistoryTemplate {
31719 version: &'static str,
31720 entries: Vec<HistoryEntryRow>,
31721 total_scans: usize,
31722 linked_count: usize,
31723 browse_error: Option<String>,
31724 watched_dirs: Vec<String>,
31725 csp_nonce: String,
31726 server_mode: bool,
31727}
31728
31729#[derive(Template)]
31732#[template(
31733 source = r##"
31734<!doctype html>
31735<html lang="en">
31736<head>
31737 <meta charset="utf-8">
31738 <meta name="viewport" content="width=device-width, initial-scale=1">
31739 <title>OxideSLOC | Compare Scans</title>
31740 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
31741 <link rel="stylesheet" href="/static/app.css">
31742 <script src="/static/app.js"></script>
31743 <style nonce="{{ csp_nonce }}">
31744 :root {
31745 --radius:18px; --bg:#f5efe8; --surface:rgba(255,255,255,0.82); --surface-2:#fbf7f2;
31746 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
31747 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
31748 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
31749 --sel-border:#6f9bff; --sel-bg:rgba(111,155,255,0.06);
31750 }
31751 body.dark-theme { --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548; --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; }
31752 *{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;}
31753 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
31754 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
31755 .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);}
31756 .top-nav-inner{max-width:1720px;margin:0 auto;padding:4px 24px;min-height:56px;display:flex;align-items:center;gap:14px;}
31757 .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));}
31758 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
31759 .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;}
31760 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;}
31761 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
31762 @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; } }
31763 .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;}
31764 .nav-pill:hover{background:rgba(255,255,255,0.18);transform:translateY(-1px);}
31765 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;}
31766 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
31767 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
31768 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
31769 .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;}
31770 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
31771 .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);}
31772 .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;}
31773 .settings-close:hover{color:var(--text);background:var(--surface-2);}
31774 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
31775 .settings-modal-body{padding:14px 16px 16px;}
31776 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
31777 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
31778 .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;}
31779 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
31780 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
31781 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
31782 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
31783 .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;}
31784 .tz-select:focus{border-color:var(--oxide);}
31785 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
31786 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
31787 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
31788 .panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:18px;flex-wrap:wrap;}
31789 .panel-header h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
31790 .panel-meta{font-size:13px;color:var(--muted);margin:0;}
31791 .compare-bar{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
31792 .controls-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px;flex-wrap:wrap;}
31793 .filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;}
31794 .filter-row{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap;}
31795 .per-page-label{font-size:13px;color:var(--muted);}
31796 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;}
31797 .filter-input{min-width:180px;cursor:text;}
31798 .table-wrap{width:100%;overflow-x:auto;}
31799 table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;}
31800 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;}
31801 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
31802 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
31803 #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;}
31804 #compare-table th:nth-child(2),#compare-table td:nth-child(2){min-width:185px;}
31805 #compare-table th:nth-child(3),#compare-table td:nth-child(3){min-width:300px;}
31806 #compare-table th:nth-child(4),#compare-table td:nth-child(4){min-width:78px;}
31807 #compare-table th:nth-child(5),#compare-table td:nth-child(5){min-width:55px;}
31808 #compare-table th:nth-child(6),#compare-table td:nth-child(6){min-width:75px;}
31809 #compare-table th:nth-child(7),#compare-table td:nth-child(7){min-width:65px;}
31810 #compare-table th:nth-child(8),#compare-table td:nth-child(8){min-width:50px;}
31811 #compare-table th:nth-child(9),#compare-table td:nth-child(9){min-width:75px;}
31812 #compare-table th:nth-child(10),#compare-table td:nth-child(10){min-width:75px;}
31813 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
31814 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
31815 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
31816 td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
31817 tr:last-child td{border-bottom:none;}
31818 tr.selected td{background:var(--sel-bg);}
31819 tr.selected td:first-child{box-shadow:inset 4px 0 0 var(--sel-border);}
31820 tr:hover:not(.selected):not(.row-locked) td{background:var(--surface-2);}
31821 tr{cursor:pointer;}
31822 tr.row-locked{opacity:.35;cursor:not-allowed;}
31823 tr.row-locked td{pointer-events:none;}
31824 .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;}
31825 .compare-all-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);flex-shrink:0;}
31826 .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;}
31827 .compare-all-btn:hover{background:rgba(111,155,255,0.18);}
31828 body.dark-theme .compare-all-btn{background:rgba(111,155,255,0.12);color:var(--accent);border-color:var(--accent);}
31829 body.dark-theme .compare-all-btn:hover{background:rgba(111,155,255,0.22);}
31830 .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);}
31831 .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);}
31832 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
31833 .metric-num{font-weight:700;color:var(--text);}
31834 .metric-secondary{font-size:11px;color:var(--muted);margin-top:2px;}
31835 .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;}
31836 .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;}
31837 tr.selected .sel-badge{background:var(--sel-border);border-color:var(--sel-border);color:#fff;}
31838 .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;}
31839 .btn:hover{background:var(--line);}
31840 .btn.primary{background:var(--accent-2);border-color:var(--accent-2);color:#fff;}
31841 .btn.primary:hover{opacity:.9;}
31842 .btn:disabled{opacity:.35;cursor:default;pointer-events:none;}
31843 .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;}
31844 .toolbar-divider{width:1px;background:var(--line);align-self:stretch;flex-shrink:0;margin:0 6px;}
31845 .toolbar-right{display:flex;align-items:center;gap:8px;flex-shrink:0;flex-wrap:wrap;}
31846 .watched-bar-left{display:flex;align-items:center;gap:8px;flex:1;min-width:0;flex-wrap:wrap;}
31847 .watched-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);white-space:nowrap;flex-shrink:0;}
31848 .watched-chips{display:flex;gap:6px;flex-wrap:wrap;flex:1;min-width:0;align-items:center;}
31849 .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;}
31850 .watched-chip-path{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
31851 .watched-chip-rm{background:none;border:none;cursor:pointer;color:var(--muted);font-size:14px;line-height:1;padding:0 2px;flex-shrink:0;}
31852 .watched-chip-rm:hover{color:var(--oxide);}
31853 .watched-none{font-size:11px;color:var(--muted);font-style:italic;}
31854 .watched-bar-right{display:flex;gap:6px;align-items:center;flex-shrink:0;}
31855 .watched-bar-right .btn{box-sizing:border-box;height:28px;}
31856 body.dark-theme .watched-chip{background:rgba(255,255,255,0.05);}
31857 .submod-chips-cell{display:flex;flex-wrap:wrap;gap:2px;align-items:flex-start;max-height:50px;overflow:hidden;}
31858 .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;}
31859 .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;}
31860 .btn-back:hover{background:var(--line);}
31861 .empty-state{text-align:center;padding:48px 24px;color:var(--muted);}
31862 .empty-state strong{display:block;font-size:18px;margin-bottom:8px;color:var(--text);}
31863 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
31864 .pagination-info{font-size:13px;color:var(--muted);}
31865 .pagination-btns{display:flex;gap:6px;}
31866 .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;}
31867 .pg-btn:hover:not(:disabled){background:var(--line);}
31868 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
31869 .pg-btn:disabled{opacity:.35;cursor:default;}
31870 .hint-right-wrap .instruction-bar{max-width:fit-content!important;width:auto!important;}
31871 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
31872 .site-footer a{color:var(--muted);}
31873 @media(max-width:700px){td,th{padding:7px 8px;}.run-id-chip,.git-chip{display:none;}}
31874 .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;}
31875 .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;}
31876 .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:13px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
31877 @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));}}
31878 .summary-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:18px;}
31879 @media(max-width:800px){.summary-strip{grid-template-columns:repeat(2,1fr);}}
31880 .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);}
31881 .stat-chip:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(77,44,20,0.2);z-index:10;}
31882 .stat-chip-val{font-size:20px;font-weight:900;color:var(--oxide);}
31883 .stat-chip-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-top:4px;}
31884 .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);}
31885 .stat-chip-tip::after{content:'';position:absolute;bottom:100%;left:50%;transform:translateX(-50%);border:5px solid transparent;border-bottom-color:var(--text);}
31886 .stat-chip:hover .stat-chip-tip{opacity:1;transform:translateX(-50%) translateY(0);}
31887 .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;}
31888 .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;}
31889 .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%;}
31890 body.dark-theme .instruction-bar{background:rgba(111,155,255,0.12);color:var(--accent);}
31891 .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;}
31892 body.dark-theme .submod-chip{background:rgba(111,155,255,0.16);border-color:rgba(111,155,255,0.32);color:var(--accent);}
31893 #compare-table td:nth-child(11){white-space:normal;overflow:visible;}
31894 .hidden{display:none!important;}
31895 .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%;}
31896 @keyframes fadeIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}}
31897 body.dark-theme .scope-panel{background:rgba(111,155,255,0.09);border-color:rgba(111,155,255,0.32);}
31898 .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;}
31899 .scope-panel-label svg{stroke:currentColor;fill:none;stroke-width:2;}
31900 .scope-options{display:flex;flex-wrap:wrap;gap:8px;}
31901 .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;}
31902 .scope-option:hover{background:var(--line);}
31903 .scope-option.selected{border-color:var(--accent-2);background:rgba(111,155,255,0.12);color:var(--accent-2);}
31904 body.dark-theme .scope-option.selected{background:rgba(111,155,255,0.18);color:var(--accent);}
31905 .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;}
31906 .scope-option.selected .scope-option-radio{border-color:var(--accent-2);}
31907 .scope-option.selected .scope-option-radio::after{content:'';position:absolute;inset:3px;border-radius:50%;background:var(--accent-2);}
31908 .scope-option-sep{width:1px;height:16px;background:rgba(111,155,255,0.28);margin:0 2px;flex-shrink:0;}
31909 .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;}
31910 </style>
31911</head>
31912<body>
31913 <div class="background-watermarks" aria-hidden="true">
31914 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31915 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31916 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31917 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31918 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31919 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
31920 </div>
31921 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
31922 <div class="top-nav">
31923 <div class="top-nav-inner">
31924 <a class="brand" href="/">
31925 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
31926 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Compare scans</div></div>
31927 </a>
31928 <div class="nav-right">
31929 <a class="nav-pill" href="/">Home</a>
31930 <div class="nav-dropdown">
31931 <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>
31932 <div class="nav-dropdown-menu">
31933 <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>
31934 </div>
31935 </div>
31936 <a class="nav-pill sx-8c38ef73" href="/compare-scans">Compare Scans</a>
31937 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
31938 <div class="nav-dropdown">
31939 <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>
31940 <div class="nav-dropdown-menu">
31941 <a href="/code-ownership"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>Code Ownership</a>
31942 <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>
31943 </div>
31944 </div>
31945 <div class="server-status-wrap" id="server-status-wrap">
31946 <div class="nav-pill server-online-pill" id="server-status-pill">
31947 <span class="status-dot" id="status-dot"></span>
31948 <span id="server-status-label">Server</span>
31949 <span class="sx-d60f2ef3" id="server-ping-ms" ></span>
31950 </div>
31951 <div class="server-status-tip">
31952 OxideSLOC is running — accessible on your network.
31953 <span class="sx-238af6bc" id="server-tip-ping" ></span>
31954 </div>
31955 </div>
31956 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
31957 <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>
31958 </button>
31959 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
31960 <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>
31961 <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>
31962 </button>
31963 </div>
31964 </div>
31965 </div>
31966
31967 <div class="page">
31968 <div class="watched-bar">
31969 <div class="watched-bar-left">
31970 <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>
31971 <span class="watched-label">Watched Folders</span>
31972 <div class="watched-chips">
31973 {% if server_mode %}
31974 <span class="watched-none">Network Server mode — watched folder settings can only be modified by the host administrator.</span>
31975 {% else %}
31976 {% for dir in watched_dirs %}
31977 <span class="watched-chip">
31978 <span class="watched-chip-path" title="{{ dir }}">{{ dir }}</span>
31979 <form class="sx-043808a9" method="POST" action="/watched-dirs/remove" >
31980 <input type="hidden" name="folder_path" value="{{ dir }}">
31981 <input type="hidden" name="redirect_to" value="/compare-scans">
31982 <button type="submit" class="watched-chip-rm" title="Remove folder">✕</button>
31983 </form>
31984 </span>
31985 {% endfor %}
31986 {% if watched_dirs.is_empty() %}
31987 <span class="watched-none">No folders watched — click Choose to add one</span>
31988 {% endif %}
31989 {% endif %}
31990 </div>
31991 </div>
31992 {% if !server_mode %}
31993 <div class="watched-bar-right">
31994 <button type="button" class="btn" id="add-watched-btn">
31995 <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>
31996 Choose
31997 </button>
31998 <form class="sx-043808a9" method="POST" action="/watched-dirs/refresh" >
31999 <input type="hidden" name="redirect_to" value="/compare-scans">
32000 <button type="submit" class="btn">↻ Refresh</button>
32001 </form>
32002 </div>
32003 {% endif %}
32004 </div>
32005 <div class="scan-overlay" id="scan-overlay" aria-hidden="true">
32006 <div class="scan-overlay-card">
32007 <div class="scan-spinner"></div>
32008 <div class="scan-overlay-text">Scanning folder…</div>
32009 <div class="scan-overlay-sub">Reading reports and building metrics — this can take a moment for large folders.</div>
32010 </div>
32011 </div>
32012 <style nonce="{{ csp_nonce }}">
32013 .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);}
32014 .scan-overlay.active{display:flex;}
32015 .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;}
32016 .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;}
32017 @keyframes scanSpin{to{transform:rotate(360deg);}}
32018 .scan-overlay-text{font-size:15px;font-weight:800;color:var(--text);}
32019 .scan-overlay-sub{font-size:12px;color:var(--muted);line-height:1.5;}
32020 </style>
32021 {% if total_scans > 0 %}
32022 <div class="summary-strip">
32023 <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>
32024 <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>
32025 <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>
32026 <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>
32027 </div>
32028 {% endif %}
32029 <section class="panel">
32030 <div class="panel-header">
32031 <div>
32032 <h1>Compare Scans</h1>
32033 <p class="panel-meta">{{ total_scans }} scan record(s) available. Select two or more scans from the same project, then press Compare.</p>
32034 </div>
32035 <div class="sx-fde5ebae" >
32036 <div class="sx-a6b122c5" >
32037 <button class="btn primary" id="compare-btn" disabled>
32038 <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>
32039 Compare <span class="sel-count" id="sel-count">0</span> Selected
32040 </button>
32041 </div>
32042 </div>
32043 </div>
32044
32045 {% if entries.is_empty() %}
32046 <div class="empty-state">
32047 <strong>No scans yet</strong>
32048 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.
32049 </div>
32050 {% else %}
32051 <div class="filter-row">
32052 <input class="filter-input" id="project-filter" type="text" placeholder="Filter by path or name…">
32053 <select class="filter-select" id="branch-filter"><option value="">All branches</option></select>
32054 <button type="button" class="btn" id="reset-view-btn">↻ Reset view</button>
32055 </div>
32056 <div class="scope-panel hidden" id="scope-panel">
32057 <div class="scope-panel-label">
32058 <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>
32059 Compare scope — choose what to include
32060 </div>
32061 <div class="scope-options" id="scope-options"></div>
32062 </div>
32063 {% if total_scans > 0 %}
32064 <div class="hint-right-wrap sx-f9538e2a" >
32065 <div class="instruction-bar sx-2f0d883e" >
32066 <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>
32067 Select rows from the <strong>same project</strong>, then press <strong>Compare</strong> — or use <strong>Compare All</strong> for a full project history.
32068 </div>
32069 </div>
32070 {% endif %}
32071 <div id="compare-all-bar" class="compare-all-bar sx-6aa34d74" >
32072 <span class="compare-all-label">
32073 <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>
32074 Quick Compare All
32075 </span>
32076 </div>
32077 <div class="table-wrap">
32078 <table id="compare-table">
32079 <colgroup><col><col><col><col><col><col><col><col><col><col><col></colgroup>
32080 <thead>
32081 <tr id="compare-thead">
32082 <th><div class="col-resize-handle"></div></th>
32083 <th class="sortable" data-sort-col="timestamp" data-sort-type="str">Timestamp<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
32084 <th class="sortable" data-sort-col="project" data-sort-type="str">Project<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
32085 <th title="Internal scan ID generated by OxideSLOC">Run ID<div class="col-resize-handle"></div></th>
32086 <th class="sortable" data-sort-col="files" data-sort-type="num">Files<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
32087 <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>
32088 <th class="sortable" data-sort-col="comments" data-sort-type="num">Comments<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
32089 <th class="sortable" data-sort-col="blank" data-sort-type="num">Blank<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
32090 <th class="sortable" data-sort-col="branch" data-sort-type="str">Branch<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
32091 <th class="sortable" data-sort-col="commit" data-sort-type="str">Commit<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
32092 <th>Submodules<div class="col-resize-handle"></div></th>
32093 </tr>
32094 </thead>
32095 <tbody id="compare-tbody">
32096 {% for entry in entries %}
32097 <tr class="compare-row" data-run="{{ entry.run_id }}" data-vid="{{ entry.run_id }}"
32098 data-timestamp="{{ entry.timestamp }}" data-sort-ts="{{ entry.timestamp_utc_ms }}"
32099 data-project="{{ entry.project_label }}"
32100 data-files="{{ entry.files_analyzed }}"
32101 data-code="{{ entry.code_lines }}"
32102 data-comments="{{ entry.comment_lines }}"
32103 data-blank="{{ entry.blank_lines }}"
32104 data-branch="{{ entry.git_branch }}"
32105 data-commit="{{ entry.git_commit }}"
32106 data-submodules="{{ entry.submodule_names_csv }}">
32107 <td><span class="sel-badge" id="badge-{{ entry.run_id }}"></span></td>
32108 <td><span class="ts-local" data-utc-ms="{{ entry.timestamp_utc_ms }}">{{ entry.timestamp }}</span></td>
32109 <td title="{{ entry.project_path }}">{{ entry.project_label }}</td>
32110 <td><span class="run-id-chip" title="OxideSLOC internal scan ID">{{ entry.run_id_short }}</span></td>
32111 <td><span class="metric-num">{{ entry.files_analyzed }}</span></td>
32112 <td><span class="metric-num">{{ entry.code_lines }}</span></td>
32113 <td><span class="metric-num">{{ entry.comment_lines }}</span></td>
32114 <td><span class="metric-num">{{ entry.blank_lines }}</span></td>
32115 <td>{% if !entry.git_branch.is_empty() %}<span class="git-chip">{{ entry.git_branch }}</span>{% else %}<span class="sx-eac76940" >—</span>{% endif %}</td>
32116 <td>{% if !entry.git_commit.is_empty() %}<span class="git-chip git-commit-chip sx-53f53688" data-full-commit="{{ entry.git_commit_long }}">{{ entry.git_commit }}</span>{% else %}<span class="sx-eac76940" >—</span>{% endif %}</td>
32117 <td class="sx-bf562f19" >{% 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 class="sx-eac76940" >—</span>{% endif %}</td>
32118 </tr>
32119 {% endfor %}
32120 </tbody>
32121 </table>
32122 </div>
32123 <div class="pagination">
32124 <span class="pagination-info" id="pagination-info"></span>
32125 <div class="pagination-btns" id="pagination-btns"></div>
32126 <div class="flex-row">
32127 <span class="per-page-label">Show</span>
32128 <select class="per-page" id="per-page-sel">
32129 <option value="10">10 per page</option>
32130 <option value="25" selected>25 per page</option>
32131 <option value="50">50 per page</option>
32132 <option value="100">100 per page</option>
32133 </select>
32134 <span class="per-page-label" id="page-range-label"></span>
32135 </div>
32136 </div>
32137 {% endif %}
32138 </section>
32139 </div>
32140
32141 <footer class="site-footer">
32142 local code analysis - metrics, history and reports
32143 · <em class="footer-mode sx-e01b0d98" id="footer-mode" >oxide-sloc v{{ version }} — Mode: Local</em>
32144 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
32145 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
32146 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
32147 · <a href="/report-bug" rel="noopener">Report a Bug</a> · <a href="/api-docs" rel="noopener">REST API</a>
32148 </footer>
32149
32150 <script nonce="{{ csp_nonce }}">
32151 (function () {
32152 // ── Theme ──────────────────────────────────────────────────────────────
32153 var storageKey = 'oxide-sloc-theme';
32154 var body = document.body;
32155 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
32156 var toggle = document.getElementById('theme-toggle');
32157 if (toggle) toggle.addEventListener('click', function () {
32158 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
32159 body.classList.toggle('dark-theme', next === 'dark');
32160 try { localStorage.setItem(storageKey, next); } catch(e) {}
32161 });
32162
32163 // ── State ─────────────────────────────────────────────────────────────
32164 var perPage = 25, currentPage = 1, sortCol = 'timestamp', sortOrder = 'desc';
32165 var allRows = Array.prototype.slice.call(document.querySelectorAll('.compare-row'));
32166 allRows.forEach(function(r, i) { r.dataset.origIdx = i; });
32167 window._allCompareRows = allRows;
32168
32169 // ── Stat chips ────────────────────────────────────────────────────────
32170 (function() {
32171 var projects = {}, latestTs = '', latestRow = null;
32172 allRows.forEach(function(r) {
32173 var p = r.dataset.project || ''; if (p) projects[p] = true;
32174 var ts = r.dataset.timestamp || '';
32175 if (!latestRow || ts > latestTs) { latestTs = ts; latestRow = r; }
32176 });
32177 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();}
32178 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>':'');}
32179 var pe = document.getElementById('agg-projects'); if (pe) pe.textContent = Object.keys(projects).filter(Boolean).length;
32180 if (latestRow) {
32181 setChipVal('agg-code', latestRow.dataset.code);
32182 setChipVal('agg-files', latestRow.dataset.files);
32183 }
32184 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(); });
32185 })();
32186
32187 // ── Branch filter population ──────────────────────────────────────────
32188 (function() {
32189 var branches = {};
32190 allRows.forEach(function(r) { var b = r.dataset.branch || ''; if (b) branches[b] = true; });
32191 var sel = document.getElementById('branch-filter');
32192 if (sel) Object.keys(branches).sort().forEach(function(b) {
32193 var opt = document.createElement('option'); opt.value = b; opt.textContent = b; sel.appendChild(opt);
32194 });
32195 })();
32196
32197 // ── Filter ────────────────────────────────────────────────────────────
32198 function getFilteredRows() {
32199 var proj = ((document.getElementById('project-filter') || {}).value || '').toLowerCase().trim();
32200 var branch = ((document.getElementById('branch-filter') || {}).value || '');
32201 return Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).filter(function(r) {
32202 if (proj && !(r.dataset.project || '').toLowerCase().includes(proj)) return false;
32203 if (branch && (r.dataset.branch || '') !== branch) return false;
32204 return true;
32205 });
32206 }
32207
32208 // ── Pagination ────────────────────────────────────────────────────────
32209 function renderPage() {
32210 var filtered = getFilteredRows();
32211 var total = filtered.length;
32212 var totalPages = Math.max(1, Math.ceil(total / perPage));
32213 currentPage = Math.min(currentPage, totalPages);
32214 var start = (currentPage - 1) * perPage;
32215 var end = Math.min(start + perPage, total);
32216 var shown = {};
32217 filtered.slice(start, end).forEach(function(r) { shown[r.dataset.run] = true; });
32218 Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row')).forEach(function(r) {
32219 r.style.display = shown[r.dataset.run] ? '' : 'none';
32220 });
32221 var rl = document.getElementById('page-range-label');
32222 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total : 'No results';
32223 var info = document.getElementById('pagination-info');
32224 if (info) info.textContent = 'Page ' + currentPage + ' of ' + totalPages;
32225 var btns = document.getElementById('pagination-btns');
32226 if (!btns) return;
32227 btns.innerHTML = '';
32228 function makeBtn(lbl, pg, active, disabled) {
32229 var b = document.createElement('button');
32230 b.className = 'pg-btn' + (active ? ' active' : '');
32231 b.textContent = lbl; b.disabled = disabled;
32232 if (!disabled) b.addEventListener('click', function() { currentPage = pg; renderPage(); });
32233 return b;
32234 }
32235 btns.appendChild(makeBtn('\u2039', currentPage - 1, false, currentPage === 1));
32236 var ws = Math.max(1, currentPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
32237 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === currentPage, false));
32238 btns.appendChild(makeBtn('\u203a', currentPage + 1, false, currentPage === totalPages));
32239 }
32240
32241 window.setPerPage = function(v) { perPage = parseInt(v, 10) || 25; currentPage = 1; renderPage(); };
32242 window.applyFilters = function() { currentPage = 1; renderPage(); };
32243
32244 // ── Sorting ───────────────────────────────────────────────────────────
32245 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#compare-thead .sortable'));
32246 function doSort(col, type, order) {
32247 var tbody = document.getElementById('compare-tbody');
32248 if (!tbody) return;
32249 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
32250 rows.sort(function(a, b) {
32251 var va = a.dataset[col] || '', vb = b.dataset[col] || '';
32252 if (type === 'num') { var na = parseFloat(va) || 0, nb = parseFloat(vb) || 0; return order === 'asc' ? na - nb : nb - na; }
32253 if (order === 'asc') return va < vb ? -1 : va > vb ? 1 : 0;
32254 return va < vb ? 1 : va > vb ? -1 : 0;
32255 });
32256 rows.forEach(function(r) { tbody.appendChild(r); });
32257 currentPage = 1; renderPage();
32258 }
32259 sortHeaders.forEach(function(th) {
32260 th.addEventListener('click', function(e) {
32261 if (e.target.classList.contains('col-resize-handle')) return;
32262 var col = th.dataset.sortCol, type = th.dataset.sortType || 'str';
32263 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
32264 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
32265 th.classList.add('sort-' + sortOrder);
32266 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
32267 doSort(col, type, sortOrder);
32268 });
32269 });
32270
32271 // Apply default sort (timestamp desc) on initial load
32272 (function() {
32273 var tsTh = document.querySelector('#compare-thead [data-sort-col="timestamp"]');
32274 if (tsTh) { tsTh.classList.add('sort-desc'); var si = tsTh.querySelector('.sort-icon'); if (si) si.textContent = '\u2193'; doSort('timestamp', 'str', 'desc'); }
32275 })();
32276
32277 // ── Column resize ─────────────────────────────────────────────────────
32278 (function() {
32279 var table = document.getElementById('compare-table');
32280 if (!table) return;
32281 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
32282 var ths = Array.prototype.slice.call(table.querySelectorAll('#compare-thead th'));
32283 ths.forEach(function(th, i) {
32284 var handle = th.querySelector('.col-resize-handle');
32285 if (!handle || !cols[i]) return;
32286 var startX, startW;
32287 handle.addEventListener('mousedown', function(e) {
32288 e.stopPropagation(); e.preventDefault();
32289 startX = e.clientX; startW = cols[i].offsetWidth || th.offsetWidth;
32290 handle.classList.add('dragging');
32291 function onMove(e) { cols[i].style.width = Math.max(40, startW + e.clientX - startX) + 'px'; }
32292 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
32293 document.addEventListener('mousemove', onMove);
32294 document.addEventListener('mouseup', onUp);
32295 });
32296 });
32297 })();
32298
32299 // ── Full-commit hover tooltip ─────────────────────────────────────────
32300 // The commit chips live inside an overflow:auto table wrapper, which would
32301 // clip a pure-CSS ::after tooltip. Render a fixed-position bubble on <body>
32302 // (escaping the scroll container) and follow the cursor. Event delegation
32303 // keeps it working after pagination/sorting re-renders the rows.
32304 (function() {
32305 var tip = document.createElement('div');
32306 tip.className = 'commit-tip';
32307 tip.setAttribute('role', 'tooltip');
32308 document.body.appendChild(tip);
32309 var shown = false;
32310 function chipFrom(t) { return t && t.closest ? t.closest('.git-commit-chip[data-full-commit]') : null; }
32311 function place(e) {
32312 var pad = 14, r = tip.getBoundingClientRect();
32313 var x = e.clientX + pad, y = e.clientY + pad;
32314 if (x + r.width > window.innerWidth - 8) x = e.clientX - r.width - pad;
32315 if (y + r.height > window.innerHeight - 8) y = e.clientY - r.height - pad;
32316 tip.style.left = x + 'px'; tip.style.top = y + 'px';
32317 }
32318 function hide() { tip.style.display = 'none'; shown = false; }
32319 document.addEventListener('mouseover', function(e) {
32320 var chip = chipFrom(e.target);
32321 if (!chip) return;
32322 var full = chip.getAttribute('data-full-commit');
32323 if (!full) return;
32324 tip.textContent = full; tip.style.display = 'block'; shown = true; place(e);
32325 });
32326 document.addEventListener('mousemove', function(e) {
32327 if (!shown) return;
32328 if (chipFrom(e.target)) place(e); else hide();
32329 });
32330 document.addEventListener('mouseout', function(e) {
32331 if (chipFrom(e.target)) hide();
32332 });
32333 })();
32334
32335 // ── Reset view ────────────────────────────────────────────────────────
32336 window.resetView = function() {
32337 var pf = document.getElementById('project-filter'); if (pf) pf.value = '';
32338 var bf = document.getElementById('branch-filter'); if (bf) bf.value = '';
32339 sortCol = null; sortOrder = 'asc';
32340 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
32341 var tbody = document.getElementById('compare-tbody');
32342 if (tbody) {
32343 var rows = Array.prototype.slice.call(tbody.querySelectorAll('.compare-row'));
32344 rows.sort(function(a, b) { return parseInt(a.dataset.origIdx || 0) - parseInt(b.dataset.origIdx || 0); });
32345 rows.forEach(function(r) { tbody.appendChild(r); });
32346 }
32347 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; perPage = 25; }
32348 var table = document.getElementById('compare-table');
32349 currentPage = 1; renderPage();
32350 currentPage = 1; renderPage();
32351 };
32352
32353 renderPage();
32354 buildCompareAllBar();
32355
32356 // ── Row selection state ───────────────────────────────────────────────
32357 var selected = [];
32358 var lockedProject = null; // project label of first selected scan
32359
32360 function updateCompareBtn() {
32361 var btn = document.getElementById('compare-btn');
32362 var cnt = document.getElementById('sel-count');
32363 if (!btn) return;
32364 btn.disabled = selected.length < 2;
32365 if (cnt) cnt.textContent = selected.length;
32366 }
32367
32368 function applyProjectLock() {
32369 var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
32370 allRows.forEach(function(r) {
32371 if (lockedProject === null) {
32372 r.classList.remove('row-locked');
32373 } else {
32374 var proj = r.dataset.project || '';
32375 if (proj !== lockedProject) {
32376 r.classList.add('row-locked');
32377 } else {
32378 r.classList.remove('row-locked');
32379 }
32380 }
32381 });
32382 }
32383
32384 function toggleRow(row) {
32385 if (row.classList.contains('row-locked')) return;
32386 var vid = row.dataset.vid || row.dataset.run;
32387 var idx = selected.indexOf(vid);
32388 if (idx >= 0) {
32389 selected.splice(idx, 1);
32390 row.classList.remove('selected');
32391 var b = document.getElementById('badge-' + vid);
32392 if (b) b.textContent = '';
32393 // Release project lock if nothing selected
32394 if (selected.length === 0) lockedProject = null;
32395 } else {
32396 // Set project lock on first selection
32397 if (selected.length === 0) lockedProject = row.dataset.project || null;
32398 selected.push(vid);
32399 row.classList.add('selected');
32400 }
32401 selected.forEach(function(v, i) {
32402 var b = document.getElementById('badge-' + v);
32403 if (b) b.textContent = i + 1;
32404 });
32405 applyProjectLock();
32406 updateCompareBtn();
32407 buildScopePanel();
32408 }
32409
32410 // ── Compare-All bar ───────────────────────────────────────────────────
32411 function buildCompareAllBar() {
32412 var bar = document.getElementById('compare-all-bar');
32413 if (!bar) return;
32414 // Group all rows by project label.
32415 var groups = {};
32416 var allRows = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
32417 // Use all rows from the source data (not just visible).
32418 var allRowsAll = Array.prototype.slice.call(document.querySelectorAll('#compare-tbody .compare-row'));
32419 // We need ALL rows across all pages, not just the rendered ones.
32420 // Use the underlying allRows array that the pagination JS also uses.
32421 var sourceRows = window._allCompareRows || allRowsAll;
32422 sourceRows.forEach(function(r) {
32423 var proj = r.dataset.project || '';
32424 var vid = r.dataset.vid || r.dataset.run || '';
32425 if (!proj || !vid) return;
32426 if (!groups[proj]) groups[proj] = { ids: [], ts: [] };
32427 groups[proj].ids.push(vid);
32428 groups[proj].ts.push(parseInt(r.dataset.sortTs || '0', 10) || 0);
32429 });
32430 // Build buttons for each project with >= 2 scans.
32431 var keys = Object.keys(groups).filter(function(k) { return groups[k].ids.length >= 2; });
32432 if (!keys.length) { bar.style.display = 'none'; return; }
32433 bar.style.display = 'flex';
32434 // Remove old buttons (keep label).
32435 var oldBtns = bar.querySelectorAll('.compare-all-btn');
32436 oldBtns.forEach(function(b) { b.remove(); });
32437 keys.sort();
32438 keys.forEach(function(proj) {
32439 var g = groups[proj];
32440 var btn = document.createElement('button');
32441 btn.className = 'compare-all-btn';
32442 btn.type = 'button';
32443 btn.textContent = proj + ' (' + g.ids.length + ' scans)';
32444 btn.title = 'Compare all ' + g.ids.length + ' scans of ' + proj;
32445 btn.addEventListener('click', function() {
32446 // Sort ids by timestamp (ascending).
32447 var pairs = g.ids.map(function(id, i) { return { id: id, ts: g.ts[i] }; });
32448 pairs.sort(function(a, b) { return a.ts - b.ts; });
32449 var sorted = pairs.map(function(p) { return p.id; });
32450 if (sorted.length === 2) {
32451 window.location.href = '/compare?a=' + encodeURIComponent(sorted[0]) + '&b=' + encodeURIComponent(sorted[1]);
32452 } else {
32453 window.location.href = '/multi-compare?runs=' + sorted.map(encodeURIComponent).join(',');
32454 }
32455 });
32456 bar.appendChild(btn);
32457 });
32458 }
32459
32460 // ── Scope panel ───────────────────────────────────────────────────────
32461 var selectedScope = 'all';
32462
32463 function buildScopePanel() {
32464 var panel = document.getElementById('scope-panel');
32465 var opts = document.getElementById('scope-options');
32466 if (!panel || !opts) return;
32467 if (selected.length < 2) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
32468
32469 // Collect union of submodules from all selected rows.
32470 var allSubs = {};
32471 selected.forEach(function(vid) {
32472 var row = document.querySelector('#compare-tbody .compare-row[data-vid="' + vid + '"]');
32473 if (!row) return;
32474 (row.dataset.submodules || '').split(',').filter(Boolean).forEach(function(s) { allSubs[s] = true; });
32475 });
32476 var subList = Object.keys(allSubs).sort();
32477 if (subList.length === 0) { panel.classList.add('hidden'); selectedScope = 'all'; return; }
32478
32479 panel.classList.remove('hidden');
32480 opts.innerHTML = '';
32481
32482 function makeOption(value, label, title) {
32483 var div = document.createElement('div');
32484 div.className = 'scope-option' + (selectedScope === value ? ' selected' : '');
32485 div.dataset.scopeValue = value;
32486 if (title) div.title = title;
32487 var radio = document.createElement('span');
32488 radio.className = 'scope-option-radio';
32489 var lbl = document.createElement('span');
32490 lbl.textContent = label;
32491 div.appendChild(radio);
32492 div.appendChild(lbl);
32493 div.addEventListener('click', function() {
32494 selectedScope = value;
32495 opts.querySelectorAll('.scope-option').forEach(function(o) {
32496 o.classList.toggle('selected', o.dataset.scopeValue === value);
32497 });
32498 });
32499 return div;
32500 }
32501
32502 opts.appendChild(makeOption('all', 'Full scan', 'All files \u2014 super-repo and submodules combined'));
32503 var sep = document.createElement('span');
32504 sep.className = 'scope-option-sep';
32505 opts.appendChild(sep);
32506 opts.appendChild(makeOption('super', 'Super-repo only', 'Only files not belonging to any submodule'));
32507 subList.forEach(function(s) {
32508 opts.appendChild(makeOption('sub:' + s, 'Submodule: ' + s, 'Only files belonging to submodule \u201c' + s + '\u201d'));
32509 });
32510 }
32511
32512 function doCompare() {
32513 if (selected.length < 2) return;
32514 if (selected.length === 2) {
32515 // Two-scan delta (existing flow with scope support).
32516 var url = '/compare?a=' + encodeURIComponent(selected[0]) + '&b=' + encodeURIComponent(selected[1]);
32517 if (selectedScope === 'super') url += '&scope=super';
32518 else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
32519 window.location.href = url;
32520 } else {
32521 // Multi-scan timeline (N >= 3) — pass scope params too.
32522 var url = '/multi-compare?runs=' + selected.map(encodeURIComponent).join(',');
32523 if (selectedScope === 'super') url += '&scope=super';
32524 else if (selectedScope.indexOf('sub:') === 0) url += '&sub=' + encodeURIComponent(selectedScope.slice(4));
32525 window.location.href = url;
32526 }
32527 }
32528
32529 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────
32530 var cbtn = document.getElementById('compare-btn');
32531 if (cbtn) cbtn.addEventListener('click', doCompare);
32532 var pfEl = document.getElementById('project-filter');
32533 if (pfEl) pfEl.addEventListener('input', function() { currentPage = 1; renderPage(); });
32534 var bfEl = document.getElementById('branch-filter');
32535 if (bfEl) bfEl.addEventListener('change', function() { currentPage = 1; renderPage(); });
32536 var rvBtn = document.getElementById('reset-view-btn');
32537 if (rvBtn) rvBtn.addEventListener('click', function() { window.resetView(); });
32538 var ppSel = document.getElementById('per-page-sel');
32539 if (ppSel) ppSel.addEventListener('change', function() { perPage = parseInt(this.value, 10) || 25; currentPage = 1; renderPage(); });
32540
32541 var cmpTbody = document.getElementById('compare-tbody');
32542 if (cmpTbody) cmpTbody.addEventListener('click', function(e) {
32543 var row = e.target.closest('.compare-row');
32544 if (row) toggleRow(row);
32545 });
32546
32547 (function randomizeWatermarks() {
32548 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
32549 if (!wms.length) return;
32550 var placed = [];
32551 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;}
32552 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];}
32553 var half=Math.floor(wms.length/2);
32554 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;});
32555 })();
32556
32557 (function spawnCodeParticles() {
32558 var container = document.getElementById('code-particles');
32559 if (!container) return;
32560 var snippets = ['NUM sloc','fn analyze()','code_lines','0 mixed','blanks: NUM','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','NUM physical','files: NUM','NUM comments','cargo build','Ok(run)','Vec<GEN>','match lang','fn main()','.rs .go .py','sloc_core','render_html','NUM code','HashMap<GEN, GN2>','Option<GEN>','&str','author AUT','last_change','ratio 0.CPX','complexity CPX','tokens: NUM','?; // try','let owner','commits: NUM','#[tokio::test]','assert_eq!','PCT% cover','blame AUT','churn PCT%','HEAD~CPX','dedup PCT%','cyclomatic CPX','impl Iterator','.await','+NUM -NM2','ownership','Span<GEN>','pub struct','trait Lang','unsafe','mixed: NUM','anyhow::Result','fn detect()','PathBuf','+NUM loc','coverage PCT%','physical: NUM','ULOC NUM','dup PCT%','let mut run']; var __GT=['Author','String','Token','FileRecord','Lang','u32','usize','PathBuf','Commit','Line','Span','Metric','Record','u64','char','Utf8','Ratio','Blame']; var __AN=['nima','core','web','cli','git','lang','ci','bot','sloc'];
32561 for (var i = 0; i < 44; i++) {
32562 (function(idx) {
32563 var el = document.createElement('span');
32564 el.className = 'code-particle';
32565 el.textContent = snippets[idx % snippets.length].split('NUM').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('NM2').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('GEN').join(__GT[Math.floor(Math.random()*__GT.length)]).split('GN2').join(__GT[Math.floor(Math.random()*__GT.length)]).split('AUT').join(__AN[Math.floor(Math.random()*__AN.length)]).split('CPX').join(''+Math.floor(Math.random()*40+1)).split('PCT').join(''+Math.floor(Math.random()*44+1));
32566 var left = Math.random() * 94 + 2;
32567 var top = Math.random() * 88 + 6;
32568 var dur = (Math.random() * 10 + 9).toFixed(1);
32569 var delay = (Math.random() * 18).toFixed(1);
32570 var rot = (Math.random() * 26 - 13).toFixed(1);
32571 var op = (Math.random() * 0.108 + 0.072).toFixed(3);
32572 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';
32573 container.appendChild(el);
32574 })(i);
32575 }
32576 })();
32577
32578 // ── Watched folder picker ─────────────────────────────────────────────
32579 (function(){
32580 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');};
32581 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);
32582 })();
32583 (function() {
32584 var btn = document.getElementById('add-watched-btn');
32585 if (!btn) return;
32586 btn.addEventListener('click', function() {
32587 fetch('/pick-directory?kind=reports')
32588 .then(function(r) { return r.ok ? r.json() : { cancelled: true }; })
32589 .then(function(data) {
32590 if (!data.cancelled && data.selected_path) {
32591 var form = document.createElement('form');
32592 form.method = 'POST';
32593 form.action = '/watched-dirs/add';
32594 var ri = document.createElement('input');
32595 ri.type = 'hidden'; ri.name = 'redirect_to'; ri.value = window.location.pathname;
32596 var fi = document.createElement('input');
32597 fi.type = 'hidden'; fi.name = 'folder_path'; fi.value = data.selected_path;
32598 form.appendChild(ri); form.appendChild(fi);
32599 document.body.appendChild(form);
32600 if (window.__scanOverlay) window.__scanOverlay();
32601 form.submit();
32602 }
32603 })
32604 .catch(function(e) { alert('Could not open folder picker: ' + e); });
32605 });
32606 })();
32607
32608 // ── Submodule chip truncation ─────────────────────────────────────────
32609 document.querySelectorAll('.submod-chips-cell').forEach(function(cell) {
32610 var chips = cell.querySelectorAll('.submod-chip');
32611 var MAX = 4;
32612 if (chips.length <= MAX) return;
32613 for (var i = MAX; i < chips.length; i++) chips[i].style.display = 'none';
32614 var badge = document.createElement('span');
32615 badge.className = 'submod-overflow-badge';
32616 badge.title = Array.from(chips).slice(MAX).map(function(c){return c.textContent;}).join(', ');
32617 badge.textContent = '+' + (chips.length - MAX) + ' more';
32618 cell.appendChild(badge);
32619 cell.style.maxHeight = 'none';
32620 });
32621 })();
32622 </script>
32623 <script nonce="{{ csp_nonce }}">
32624 (function(){
32625 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'}];
32626 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);});}
32627 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
32628 function init(){
32629 var btn=document.getElementById('settings-btn');if(!btn)return;
32630 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
32631 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 class="sx-dec3b281" ><div class="settings-modal-label sx-c500155b" >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>';
32632 document.body.appendChild(m);
32633 var g=document.getElementById('scheme-grid');
32634 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);});
32635 var cl=document.getElementById('settings-close');
32636 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);});})();
32637 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');});
32638 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
32639 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
32640 }
32641 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
32642 }());
32643 </script>
32644 <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]';
32645 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;}
32646 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>
32647</body>
32648</html>
32649"##,
32650 ext = "html"
32651)]
32652struct CompareSelectTemplate {
32653 version: &'static str,
32654 entries: Vec<HistoryEntryRow>,
32655 total_scans: usize,
32656 watched_dirs: Vec<String>,
32657 csp_nonce: String,
32658 server_mode: bool,
32659}
32660
32661#[derive(Template)]
32664#[template(
32665 source = r##"
32666<!doctype html>
32667<html lang="en">
32668<head>
32669 <meta charset="utf-8">
32670 <meta name="viewport" content="width=device-width, initial-scale=1">
32671 <title>OxideSLOC | Scan Delta</title>
32672 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
32673 <link rel="stylesheet" href="/static/app.css">
32674 <script src="/static/app.js"></script>
32675 <style nonce="{{ csp_nonce }}">
32676 :root {
32677 --radius:18px; --bg:#f5efe8; --surface:#fbf7f2; --surface-2:#f4ede4;
32678 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08777;
32679 --nav:#283790; --nav-2:#013e6b;
32680 --accent:#6f9bff; --oxide:#d37a4c; --oxide-2:#b35428; --shadow:0 18px 42px rgba(77,44,20,0.12);
32681 --pos:#1a8f47; --pos-bg:#e8f5ed; --neg:#b33b3b; --neg-bg:#fcd6d6; --zero-bg:transparent;
32682 --added:#1a8f47; --removed:#b33b3b; --modified:#926000; --unchanged:#7b675b;
32683 }
32684 body.dark-theme {
32685 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6c5649; --text:#f5ece6;
32686 --muted:#c7b7aa; --muted-2:#aa9485; --pos:#8fe2a8; --pos-bg:#163927; --neg:#ff6b6b; --neg-bg:#4a1e1e;
32687 }
32688 *{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;}
32689 .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);}
32690 .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;}
32691 .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));}
32692 .brand-copy{display:flex;flex-direction:column;justify-content:center;flex-shrink:0;}
32693 .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;}
32694 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
32695 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
32696 @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; } }
32697 .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;}
32698 .theme-toggle{width:38px;justify-content:center;padding:0;cursor:pointer;transition:transform 0.15s ease;}
32699 .theme-toggle:hover{transform:translateY(-1px);background:rgba(255,255,255,0.16);}
32700 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
32701 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
32702 .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;}
32703 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
32704 .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);}
32705 .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;}
32706 .settings-close:hover{color:var(--text);background:var(--surface-2);}
32707 .settings-close svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2.5;}
32708 .settings-modal-body{padding:14px 16px 16px;}
32709 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
32710 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
32711 .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;}
32712 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
32713 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
32714 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
32715 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
32716 .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;}
32717 .tz-select:focus{border-color:var(--oxide);}
32718 .page{width:100%;max-width:1720px;margin:0 auto;padding:18px 24px 36px;position:relative;z-index:1;}
32719 @media (max-width:1920px) { .top-nav-inner { max-width:1500px; } .page { max-width:1500px; } }
32720 .panel{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow);padding:22px;margin-bottom:18px;}
32721 .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;}
32722 .hero-header{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;margin-bottom:20px;flex-wrap:wrap;}
32723 .hero-body{display:block;}
32724 .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;}
32725 .btn-back:hover{background:var(--line);}
32726 h1{margin:0 0 6px;font-size:36px;font-weight:850;letter-spacing:-0.03em;}
32727 h2{margin:0 0 14px;font-size:18px;font-weight:750;}
32728 .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;}
32729 .delta-desc{font-size:13px;color:var(--muted);margin:0 0 8px;line-height:1.5;}
32730 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;}
32731 .muted{color:var(--muted);font-size:14px;}
32732 .version-pills{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px;}
32733 .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;}
32734 .vpill-label{font-size:11px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);}
32735 .vpill-id{font-family:ui-monospace,monospace;font-size:12px;color:var(--muted);}
32736 .vpill-arrow{font-size:20px;color:var(--muted);}
32737 .meta-strip{display:grid;grid-template-columns:1fr 1fr;gap:14px;width:100%;margin-bottom:14px;}
32738 .delta-strip{display:grid;grid-template-columns:minmax(110px,1fr) minmax(110px,1fr) minmax(110px,1fr) minmax(180px,1.5fr);gap:12px;width:100%;}
32739 .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;}
32740 .delta-card.delta-card-wide{padding:22px 24px;}
32741 .delta-card.delta-card-meta{border:1.5px solid var(--oxide);background:var(--surface);min-height:210px;justify-content:flex-start;padding:28px 30px;}
32742 body.dark-theme .delta-card.delta-card-meta{background:var(--surface-2);}
32743 .delta-card-label{font-size:13px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-2);margin-bottom:12px;}
32744 .delta-card-from{font-size:15px;color:var(--muted);}
32745 .delta-card-to{font-size:28px;font-weight:800;margin:4px 0;}
32746 .meta-card-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:12px;}
32747 .meta-card-project-col{display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-width:55%;min-width:0;}
32748 .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%;}
32749 .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;}
32750 .meta-scope-tag svg{flex:0 0 auto;stroke:currentColor;fill:none;stroke-width:2.2;}
32751 .scope-full{background:rgba(160,136,120,0.10);border:1px solid rgba(160,136,120,0.28);color:var(--muted-2);}
32752 .scope-super{background:rgba(211,122,76,0.10);border:1px solid rgba(211,122,76,0.32);color:var(--oxide-2);}
32753 .scope-sub{background:rgba(111,155,255,0.12);border:1px solid rgba(111,155,255,0.32);color:var(--accent-2);}
32754 body.dark-theme .scope-sub{background:rgba(111,155,255,0.18);border-color:rgba(111,155,255,0.38);color:var(--accent);}
32755 body.dark-theme .scope-super{background:rgba(211,122,76,0.16);border-color:rgba(211,122,76,0.36);color:var(--oxide);}
32756 .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;}
32757 .meta-card-commit:hover{color:var(--oxide);}
32758 .meta-card-rows{display:flex;flex-direction:column;gap:6px;}
32759 .meta-card-row{display:flex;align-items:baseline;gap:8px;font-size:13px;}
32760 .meta-label{font-size:11px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted-2);white-space:nowrap;flex-shrink:0;}
32761 .meta-value{color:var(--text);font-size:13px;}
32762 .cmp-author-handle{font-size:11px;font-weight:600;color:var(--muted-2);margin-left:1.5em;font-family:ui-monospace,monospace;}
32763 .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;}
32764 .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);}
32765 .delta-card:hover .dc-tip{display:block;}
32766 .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;}
32767 .export-btn:hover{background:var(--line);}
32768 .export-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;}
32769 .panel-title{font-size:14px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);margin-bottom:14px;}
32770 .delta-card-change{font-size:15px;font-weight:700;border-radius:6px;padding:2px 8px;display:inline-block;margin-top:4px;}
32771 .delta-card-change.pos{color:var(--pos);background:var(--pos-bg);}
32772 .delta-card-change.neg{color:var(--neg);background:var(--neg-bg);}
32773 .delta-card-change.zero{color:var(--muted);background:transparent;}
32774 .delta-card-pct{font-size:14px;font-weight:700;margin-top:5px;letter-spacing:.01em;}
32775 .delta-card-pct.pos{color:var(--pos);}
32776 .delta-card-pct.neg{color:var(--neg);}
32777 .delta-card-pct.zero{color:var(--muted);}
32778 .insights-panel{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;}
32779 .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;}
32780 .insight-card.insight-flag{border-color:var(--oxide);}
32781 .insight-card:hover .dc-tip{display:block;}
32782 .dc-tip.up{top:auto;bottom:calc(100% + 8px);}
32783 .dc-tip.up::after{bottom:auto;top:100%;border-bottom-color:transparent;border-top-color:rgba(20,12,8,0.96);}
32784 .insight-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--muted-2);margin-bottom:4px;}
32785 .insight-label.flag{color:var(--oxide);}
32786 .insight-val{font-size:18px;font-weight:800;line-height:1.2;}
32787 .insight-val.pos{color:var(--pos);}
32788 .insight-val.neg{color:var(--neg);}
32789 .insight-val.high{color:#c0392a;}
32790 .insight-val.med{color:#926000;}
32791 .insight-val.low{color:var(--pos);}
32792 body.dark-theme .insight-val.high{color:#ff6b6b;}
32793 body.dark-theme .insight-val.med{color:#f0c060;}
32794 .insight-sub{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.4;}
32795 .file-changes-grid{display:flex;flex-direction:column;gap:5px;margin-top:6px;font-size:12px;}
32796 .fc-row{display:flex;align-items:center;gap:8px;}
32797 .fc-count{font-weight:800;font-size:16px;min-width:28px;}
32798 .fc-label{color:var(--muted);}
32799 .fc-modified .fc-count{color:#926000;}
32800 .fc-added .fc-count{color:var(--pos);}
32801 .fc-removed .fc-count{color:var(--neg);}
32802 .fc-unchanged .fc-count{color:var(--muted);}
32803 .fc-total{border-top:1px solid var(--line);margin-top:3px;padding-top:5px;}
32804 .fc-total .fc-count{color:var(--text);}
32805 .fc-total .fc-label{font-weight:700;}
32806 body.dark-theme .fc-modified .fc-count{color:#f0c060;}
32807 .change-summary{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;}
32808 .chip{padding:4px 12px;border-radius:999px;font-size:13px;font-weight:700;}
32809 .chip.modified{background:#fff2d8;color:#926000;}
32810 .chip.added{background:#e8f5ed;color:#1a8f47;}
32811 .chip.removed{background:#fdeaea;color:#b33b3b;}
32812 .chip.unchanged{background:var(--surface-2);color:var(--muted);}
32813 body.dark-theme .chip.modified{background:#3d2f0a;color:#f0c060;}
32814 body.dark-theme .chip.added{background:#163927;color:#8fe2a8;}
32815 body.dark-theme .chip.removed{background:#3d1c1c;color:#f5a3a3;}
32816 .filter-tabs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px;}
32817 .filter-tabs{display:flex;gap:8px;flex-wrap:wrap;flex:1;}
32818 .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;}
32819 .tab-btn.active{background:var(--accent,#6f9bff);border-color:var(--accent,#6f9bff);color:#fff;}
32820 .tab-btn:hover:not(.active){background:var(--line);}
32821 .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;}
32822 .btn-reset:hover{background:var(--line);}
32823 .table-wrap{width:100%;overflow-x:auto;}
32824 table{width:100%;border-collapse:collapse;font-size:12px;table-layout:auto;}
32825 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);}
32826 th.sortable{cursor:pointer;} th.sortable:hover{color:var(--oxide);}
32827 .sort-icon{margin-left:4px;font-size:10px;opacity:0.45;display:inline-block;vertical-align:middle;}
32828 th.sort-asc .sort-icon,th.sort-desc .sort-icon{opacity:1;color:var(--oxide);}
32829 .col-resize-handle{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:2;}
32830 .col-resize-handle:hover,.col-resize-handle.dragging{background:rgba(211,122,76,0.3);}
32831 td{padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:middle;white-space:nowrap;}
32832 tr:last-child td{border-bottom:none;}
32833 tr:hover td{background:var(--surface-2);}
32834 .col-num{text-align:right;font-variant-numeric:tabular-nums;}
32835 #delta-table th:nth-child(n+4),#delta-table td:nth-child(n+4){text-align:right;font-variant-numeric:tabular-nums;}
32836 #delta-table th:last-child,#delta-table td:last-child{padding-right:14px;}
32837 /* Fixed layout: column widths come from the colgroup, not from scanning every
32838 row. With auto layout a large file matrix forces the browser to re-measure
32839 all cells on each reflow, which freezes the page during sort/resize. */
32840 #delta-table{table-layout:fixed;}
32841 #delta-table col:nth-child(1){width:32%;}
32842 #delta-table col:nth-child(2){width:11%;}
32843 #delta-table col:nth-child(3){width:11%;}
32844 #delta-table col:nth-child(4){width:16%;}
32845 #delta-table col:nth-child(5){width:10%;}
32846 #delta-table col:nth-child(6){width:10%;}
32847 #delta-table col:nth-child(7){width:10%;}
32848 tr.row-added td{background:rgba(26,143,71,0.04);}
32849 tr.row-removed td{background:rgba(179,59,59,0.06);}
32850 tr.row-modified td{background:rgba(146,96,0,0.04);}
32851 tr.row-unchanged td{color:var(--muted);}
32852 tr.row-unchanged .status-badge{opacity:.65;}
32853 .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;}
32854 .status-badge{padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;text-transform:uppercase;}
32855 .status-badge.added{background:#e8f5ed;color:#1a8f47;}
32856 .status-badge.removed{background:#fdeaea;color:#b33b3b;}
32857 .status-badge.modified{background:#fff2d8;color:#926000;}
32858 .status-badge.unchanged{background:var(--surface-2);color:var(--muted);}
32859 body.dark-theme .status-badge.added{background:#163927;color:#8fe2a8;}
32860 body.dark-theme .status-badge.removed{background:#3d1c1c;color:#f5a3a3;}
32861 body.dark-theme .status-badge.modified{background:#3d2f0a;color:#f0c060;}
32862 .delta-val{font-weight:700;}
32863 .delta-val.pos{color:var(--pos);}
32864 .delta-val.neg{color:var(--neg);}
32865 .delta-val.zero{color:var(--muted);}
32866 .from-to{display:flex;align-items:center;gap:5px;white-space:nowrap;font-size:13px;}
32867 .from-to strong{color:var(--text);font-weight:700;}
32868 .from-to .ft-sep{color:var(--muted-2);font-size:11px;}
32869 .from-to .ft-absent{color:var(--muted);font-weight:600;}
32870 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
32871 .site-footer a{color:var(--muted);}
32872 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;}
32873 body.pdf-mode{background:#fff!important;}
32874 body.pdf-mode .page{padding:4px 6px 4px!important;}
32875 @media(max-width:900px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:repeat(2,1fr);}}
32876 @media(max-width:600px){.meta-strip{grid-template-columns:1fr;}.delta-strip{grid-template-columns:1fr;} th.hide-sm,td.hide-sm{display:none;}}
32877 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
32878 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
32879 .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;}
32880 .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;}
32881 .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:13px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
32882 @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));}}
32883 .path-link{color:var(--oxide);text-decoration:underline;text-underline-offset:3px;cursor:pointer;}
32884 .path-link:hover{color:var(--oxide-2);}
32885 .vpill-meta{font-size:11px;color:var(--muted);margin-top:2px;font-style:italic;}
32886 a.vpill-id{color:var(--accent);text-decoration:underline;text-underline-offset:2px;}
32887 a.vpill-id:hover{color:var(--oxide);}
32888 .delta-note{font-size:11px;color:var(--muted);font-style:italic;text-align:right;}
32889 .pagination{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:18px;flex-wrap:wrap;}
32890 .pagination-info{font-size:13px;color:var(--muted);}
32891 .pagination-btns{display:flex;gap:6px;}
32892 .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;}
32893 .pg-btn:hover:not(:disabled){background:var(--line);}
32894 .pg-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
32895 .pg-btn:disabled{opacity:.35;cursor:default;}
32896 .per-page-label{font-size:13px;color:var(--muted);}
32897 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;}
32898 .tab-btn.tab-all.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
32899 .tab-btn.tab-modified{background:#fff2d8;color:#926000;border-color:#e6c96c;}
32900 .tab-btn.tab-modified.active{background:#926000;border-color:#926000;color:#fff;}
32901 .tab-btn.tab-added{background:#e8f5ed;color:#1a8f47;border-color:#a3d9b1;}
32902 .tab-btn.tab-added.active{background:#1a8f47;border-color:#1a8f47;color:#fff;}
32903 .tab-btn.tab-removed{background:#fdeaea;color:#b33b3b;border-color:#f5a3a3;}
32904 .tab-btn.tab-removed.active{background:#b33b3b;border-color:#b33b3b;color:#fff;}
32905 .tab-btn.tab-unchanged{color:var(--muted);}
32906 body.dark-theme .tab-btn.tab-modified{background:#3d2f0a;color:#f0c060;border-color:#6b5020;}
32907 body.dark-theme .tab-btn.tab-added{background:#163927;color:#8fe2a8;border-color:#2a6b4a;}
32908 body.dark-theme .tab-btn.tab-removed{background:#3d1c1c;color:#f5a3a3;border-color:#7a3a3a;}
32909 .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;}
32910 .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;}
32911 .submod-scope-divider{width:1px;height:18px;background:var(--line-strong);margin:0 4px;flex-shrink:0;}
32912 .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;}
32913 .submod-scope-label svg{stroke:currentColor;fill:none;stroke-width:2;}
32914 .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;}
32915 .submod-scope-btn:hover{background:var(--line);}
32916 .submod-scope-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
32917 .submod-scope-hint{font-size:11px;color:var(--muted);margin-left:auto;white-space:nowrap;}
32918 .ic-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
32919 @media(max-width:800px){.ic-grid{grid-template-columns:1fr;}}
32920 .ic-card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:16px 20px;}
32921 body.dark-theme .ic-card{background:var(--surface-2);}
32922 .ic-card-h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--muted-2);margin:0 0 10px;}
32923 .ic-leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;flex-wrap:wrap;}
32924 .ic-leg-item{cursor:pointer;transition:opacity .15s;border-radius:4px;padding:2px 6px;}
32925 .ic-leg-item:hover{background:rgba(211,122,76,0.08);}
32926 .ic-dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}
32927 .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);}
32928 .ic-card-h2-row{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}
32929 .ic-card-h2-row .ic-card-h2{margin:0;}
32930 .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;}
32931 .ic-expand-btn:hover{background:var(--surface-2);color:var(--text);}
32932 .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;}
32933 .ic-svg-modal-ov.open{display:flex;}
32934 .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);}
32935 body.dark-theme .ic-svg-modal{background:var(--surface-2);}
32936 .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);}
32937 .ic-svg-modal-title{font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--muted-2);}
32938 .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;}
32939 .ic-svg-modal-close:hover{background:var(--line);}
32940 .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;}
32941 .chart-metric-btn.active{background:var(--oxide-2);border-color:var(--oxide-2);color:#fff;}
32942 .chart-metric-btn:hover:not(.active){background:var(--line);}
32943 .chart-wrap{width:100%;overflow-x:auto;}
32944 #cmp-tl-svg{display:block;width:100%;}
32945 .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);}
32946 body.dark-theme .git-chip{background:rgba(111,155,255,0.12);border-color:rgba(111,155,255,0.25);color:var(--accent);}
32947 #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;}
32948 </style>
32949</head>
32950<body>
32951 {{ loading_overlay|safe }}
32952 <div class="background-watermarks" aria-hidden="true">
32953 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
32954 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
32955 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
32956 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
32957 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
32958 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
32959 </div>
32960 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
32961 <div class="top-nav">
32962 <div class="top-nav-inner">
32963 <a class="brand" href="/">
32964 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
32965 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">Scan Delta</div></div>
32966 </a>
32967 <div class="nav-right">
32968 <a class="nav-pill" href="/">Home</a>
32969 <div class="nav-dropdown">
32970 <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>
32971 <div class="nav-dropdown-menu">
32972 <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>
32973 </div>
32974 </div>
32975 <a class="nav-pill sx-8c38ef73" href="/compare-scans">Compare Scans</a>
32976 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
32977 <div class="nav-dropdown">
32978 <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>
32979 <div class="nav-dropdown-menu">
32980 <a href="/code-ownership"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>Code Ownership</a>
32981 <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>
32982 </div>
32983 </div>
32984 <div class="server-status-wrap" id="server-status-wrap">
32985 <div class="nav-pill server-online-pill" id="server-status-pill">
32986 <span class="status-dot" id="status-dot"></span>
32987 <span id="server-status-label">Server</span>
32988 <span class="sx-d60f2ef3" id="server-ping-ms" ></span>
32989 </div>
32990 <div class="server-status-tip">
32991 OxideSLOC is running — accessible on your network.
32992 <span class="sx-238af6bc" id="server-tip-ping" ></span>
32993 </div>
32994 </div>
32995 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
32996 <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>
32997 </button>
32998 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
32999 <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>
33000 <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>
33001 </button>
33002 </div>
33003 </div>
33004 </div>
33005
33006 <div class="page">
33007 <section class="hero">
33008 <div class="hero-header">
33009 <div>
33010 <h1 class="delta-title">Scan Delta</h1>
33011 <p class="delta-desc">Side-by-side metric comparison between two scans — code line deltas, file changes, and language breakdown.</p>
33012 <div class="sx-3d0e741f" >
33013 {% if let Some(sub) = active_submodule %}
33014 <span class="muted sx-43e9c079" >Submodule <strong>{{ sub }}</strong> — two scans of</span>
33015 {% else if super_scope_active %}
33016 <span class="muted sx-43e9c079" >Super-repo only (submodules excluded) — two scans of</span>
33017 {% else %}
33018 <span class="muted sx-43e9c079" >Full scan — two scans of</span>
33019 {% endif %}
33020 <a class="path-link sx-eac12633" id="project-path-link" data-folder="{{ project_path }}" href="#" >{{ project_path }}</a>
33021 </div>
33022 </div>
33023 <div class="sx-543fb39c" >
33024 <a class="btn-back" href="/compare-scans">
33025 <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>
33026 Compare Scans
33027 </a>
33028 <div class="export-group sx-b6d781cb" >
33029 <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>
33030 <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>
33031 </div>
33032 </div>
33033 </div>
33034 {% if has_any_submodule_data %}
33035 <div class="submod-scope-bar">
33036 <span class="submod-scope-label">
33037 <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>
33038 Scope:
33039 </span>
33040 <div class="submod-scope-divider"></div>
33041 <a class="submod-scope-btn{% if active_submodule.is_none() && !super_scope_active %} active{% endif %}"
33042 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}"
33043 title="All files — super-repo and all submodules combined">Full scan</a>
33044 <a class="submod-scope-btn{% if super_scope_active %} active{% endif %}"
33045 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&scope=super"
33046 title="Only files that are not part of any submodule">Super-repo only</a>
33047 {% for sub in submodule_options %}
33048 <a class="submod-scope-btn{% if active_submodule.as_deref() == Some(sub.as_str()) %} active{% endif %}"
33049 href="/compare?a={{ baseline_run_id }}&b={{ current_run_id }}&sub={{ sub }}"
33050 title="Only files belonging to submodule {{ sub }}">{{ sub }}</a>
33051 {% endfor %}
33052 </div>
33053 {% endif %}
33054 <div class="hero-body">
33055 <div class="meta-strip">
33056 <div class="delta-card delta-card-meta">
33057 <div class="meta-card-header">
33058 <div class="delta-card-label sx-d7014559" >Baseline</div>
33059 <div class="meta-card-project-col">
33060 <div class="meta-card-project">{{ project_name }}</div>
33061 {% if has_any_submodule_data %}
33062 {% if let Some(sub) = active_submodule %}
33063 <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>
33064 {% else if super_scope_active %}
33065 <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>
33066 {% else %}
33067 <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>
33068 {% endif %}
33069 {% endif %}
33070 </div>
33071 </div>
33072 {% if !baseline_git_commit.is_empty() %}
33073 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_git_commit }}</a>
33074 {% else %}
33075 <a class="meta-card-commit" href="/runs/html/{{ baseline_run_id }}" target="_blank">{{ baseline_run_id_short }}</a>
33076 {% endif %}
33077 <div class="meta-card-rows">
33078 <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>
33079 <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>
33080 <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>
33081 <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>
33082 <div class="meta-card-row"><span class="meta-label">Scanned by:</span>{% if !baseline_performed_by.is_empty() %}<span class="git-chip">{{ baseline_performed_by }}</span>{% else %}<span class="meta-value">—</span>{% endif %}</div>
33083 {% if let Some(tags) = baseline_git_tags %}
33084 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
33085 {% endif %}
33086 </div>
33087 </div>
33088 <div class="delta-card delta-card-meta">
33089 <div class="meta-card-header">
33090 <div class="delta-card-label sx-d7014559" >Current</div>
33091 <div class="meta-card-project-col">
33092 <div class="meta-card-project">{{ project_name }}</div>
33093 {% if has_any_submodule_data %}
33094 {% if let Some(sub) = active_submodule %}
33095 <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>
33096 {% else if super_scope_active %}
33097 <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>
33098 {% else %}
33099 <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>
33100 {% endif %}
33101 {% endif %}
33102 </div>
33103 </div>
33104 {% if !current_git_commit.is_empty() %}
33105 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_git_commit }}</a>
33106 {% else %}
33107 <a class="meta-card-commit" href="/runs/html/{{ current_run_id }}" target="_blank">{{ current_run_id_short }}</a>
33108 {% endif %}
33109 <div class="meta-card-rows">
33110 <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>
33111 <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>
33112 <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>
33113 <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>
33114 <div class="meta-card-row"><span class="meta-label">Scanned by:</span>{% if !current_performed_by.is_empty() %}<span class="git-chip">{{ current_performed_by }}</span>{% else %}<span class="meta-value">—</span>{% endif %}</div>
33115 {% if let Some(tags) = current_git_tags %}
33116 <div class="meta-card-row"><span class="meta-label">Tags:</span><span class="meta-value">{{ tags }}</span></div>
33117 {% endif %}
33118 </div>
33119 </div>
33120 </div>
33121 <div class="delta-strip">
33122 <div class="delta-card">
33123 <div class="dc-tip">Executable source lines.<br>Excludes comments and blanks.<br>Positive delta = more code written.</div>
33124 <div class="delta-card-label">Code lines</div>
33125 <div class="delta-card-from">Before: {{ baseline_code_fmt }}</div>
33126 <div class="delta-card-to">{{ current_code_fmt }}</div>
33127 {% 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>
33128 {% 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>
33129 {% else %}<div class="delta-card-pct zero">±0%</div>
33130 {% endif %}
33131 </div>
33132 <div class="delta-card">
33133 <div class="dc-tip">Source files where language detection succeeded.<br>Changes reflect files added, removed, or reclassified between scans.</div>
33134 <div class="delta-card-label">Files analyzed</div>
33135 <div class="delta-card-from">Before: {{ baseline_files_fmt }}</div>
33136 <div class="delta-card-to">{{ current_files_fmt }}</div>
33137 {% 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>
33138 {% 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>
33139 {% else %}<div class="delta-card-pct zero">±0%</div>
33140 {% endif %}
33141 </div>
33142 <div class="delta-card">
33143 <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>
33144 <div class="delta-card-label">Comment lines</div>
33145 <div class="delta-card-from">Before: {{ baseline_comments_fmt }}</div>
33146 <div class="delta-card-to">{{ current_comments_fmt }}</div>
33147 {% 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>
33148 {% 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>
33149 {% else %}<div class="delta-card-pct zero">±0%</div>
33150 {% endif %}
33151 </div>
33152 {{ coverage_delta_card|safe }}
33153 <div class="delta-card delta-card-wide">
33154 <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>
33155 <div class="delta-card-label">File changes</div>
33156 <div class="file-changes-grid">
33157 <div class="fc-row fc-modified"><span class="fc-count">{{ files_modified|commas }}</span><span class="fc-label">Modified</span></div>
33158 <div class="fc-row fc-added"><span class="fc-count">{{ files_added|commas }}</span><span class="fc-label">Added</span></div>
33159 <div class="fc-row fc-removed"><span class="fc-count">{{ files_removed|commas }}</span><span class="fc-label">Removed</span></div>
33160 <div class="fc-row fc-unchanged"><span class="fc-count">{{ files_unchanged|commas }}</span><span class="fc-label">Unchanged (identical code counts)</span></div>
33161 <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>
33162 </div>
33163 </div>
33164 </div>
33165 <div class="insights-panel">
33166 <div class="insight-card">
33167 <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>
33168 <div class="insight-label">Lines Added</div>
33169 <div class="insight-val pos">+{{ code_lines_added }}</div>
33170 <div class="insight-sub">New or grown source lines</div>
33171 </div>
33172 <div class="insight-card">
33173 <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>
33174 <div class="insight-label">Lines Removed</div>
33175 <div class="insight-val neg">−{{ code_lines_removed }}</div>
33176 <div class="insight-sub">Deleted or shrunk source lines</div>
33177 </div>
33178 <div class="insight-card">
33179 <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>
33180 <div class="insight-label">Lines Modified</div>
33181 <div class="insight-val">{{ code_lines_modified }}</div>
33182 <div class="insight-sub">Code lines in modified files</div>
33183 </div>
33184 <div class="insight-card">
33185 <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>
33186 <div class="insight-label">Lines Unmodified</div>
33187 <div class="insight-val">{{ code_lines_unmodified }}</div>
33188 <div class="insight-sub">Code lines in unchanged files</div>
33189 </div>
33190 <div class="insight-card">
33191 <div class="dc-tip up">Sum of the added, removed, modified, and unmodified code-line metrics across the two scans.</div>
33192 <div class="insight-label">Lines Total</div>
33193 <div class="insight-val">{{ code_lines_total }}</div>
33194 <div class="insight-sub">Added + removed + modified + unmodified</div>
33195 </div>
33196 <div class="insight-card">
33197 <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>
33198 <div class="insight-label">Churn Rate</div>
33199 <div class="insight-val {{ churn_rate_class }}">{{ churn_rate_str }}</div>
33200 <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>
33201 </div>
33202 {% if scope_flag %}
33203 <div class="insight-card insight-flag">
33204 <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>
33205 <div class="insight-label flag">Scope Signal</div>
33206 <div class="insight-val high">{% if new_scope %}New{% else %}{{ code_lines_pct_str }}{% endif %}</div>
33207 <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>
33208 </div>
33209 {% endif %}
33210 </div>
33211 </div>
33212 </section>
33213
33214 <section class="panel" id="inline-charts-section">
33215 <div class="panel-title">Scan Delta Charts</div>
33216 <div class="ic-grid">
33217 <div class="ic-card sx-aeb7cdee" >
33218 <div class="ic-card-h2-row">
33219 <span class="ic-card-h2">Timeline</span>
33220 <div class="cmp-tl-btns sx-98cced4e" >
33221 <button class="chart-metric-btn active" data-cmp-metric="code">Code Lines</button>
33222 <button class="chart-metric-btn" data-cmp-metric="files">Files</button>
33223 <button class="chart-metric-btn" data-cmp-metric="comments">Comments</button>
33224 <button class="chart-metric-btn" data-cmp-metric="tests">Tests</button>
33225 <button class="chart-metric-btn" data-cmp-metric="cov">Coverage</button>
33226 </div>
33227 <button class="ic-expand-btn" data-expand-src="cmp-tl-svg" data-expand-title="Timeline">⤢ Full View</button>
33228 </div>
33229 <div class="chart-wrap"><svg id="cmp-tl-svg" width="100%" height="280"></svg></div>
33230 </div>
33231 <div class="ic-card">
33232 <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>
33233 <div class="ic-leg"><span class="ic-leg-item" data-highlight="Code Lines"><span class="ic-dot sx-ec93ae6b" ></span><span class="sx-d50d9131" >Code Lines</span></span><span class="ic-leg-item" data-highlight="Files Analyzed"><span class="ic-dot sx-bbb79db3" ></span><span class="sx-f6800712" >Files</span></span><span class="ic-leg-item" data-highlight="Comments"><span class="ic-dot sx-6353c5b0" ></span><span class="sx-45650046" >Comments</span></span></div>
33234 <div id="ic-c1"></div>
33235 </div>
33236 <div class="ic-card" id="ic-lang-card">
33237 <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>
33238 <div id="ic-c3"></div>
33239 </div>
33240 <div class="ic-card">
33241 <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>
33242 <div id="ic-c2"></div>
33243 </div>
33244 <div class="ic-card">
33245 <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>
33246 <div id="ic-c4"></div>
33247 </div>
33248 </div>
33249 <div class="ic-svg-modal-ov" id="ic-svg-modal-ov">
33250 <div class="ic-svg-modal">
33251 <div class="ic-svg-modal-hdr">
33252 <span class="ic-svg-modal-title" id="ic-svg-modal-title"></span>
33253 <button type="button" class="ic-svg-modal-close" id="ic-svg-modal-close">× Close</button>
33254 </div>
33255 <div id="ic-svg-modal-body"></div>
33256 </div>
33257 </div>
33258 </section>
33259
33260 <section class="panel">
33261 <div class="panel-title">File Matrix <span class="sx-8bdabd9b" >{{ (files_modified + files_added + files_removed + files_unchanged)|commas }} files</span></div>
33262 <div class="sx-a86a62cc" >
33263 <div class="filter-tabs sx-98cced4e" >
33264 <button class="tab-btn tab-all active" data-filter="all">All ({{ (files_modified + files_added + files_removed + files_unchanged)|commas }})</button>
33265 <button class="tab-btn tab-modified" data-filter="modified">Modified ({{ files_modified|commas }})</button>
33266 <button class="tab-btn tab-added" data-filter="added">Added ({{ files_added|commas }})</button>
33267 <button class="tab-btn tab-removed" data-filter="removed">Removed ({{ files_removed|commas }})</button>
33268 <button class="tab-btn tab-unchanged" data-filter="unchanged">Unchanged ({{ files_unchanged|commas }})</button>
33269 </div>
33270 <div class="sx-fde5ebae" >
33271 <span class="delta-note">* Δ = delta (change from baseline → current)</span>
33272 <div class="export-group">
33273 <button type="button" class="export-btn" id="delta-reset-btn">↻ Reset</button>
33274 <button type="button" class="export-btn" id="delta-csv-btn">
33275 <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>
33276 CSV
33277 </button>
33278 <button type="button" class="export-btn" id="delta-xls-btn">
33279 <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>
33280 Excel
33281 </button>
33282 </div>
33283 </div>
33284 </div>
33285
33286 <div class="table-wrap">
33287 <table id="delta-table">
33288 <colgroup>
33289 <col>
33290 <col>
33291 <col>
33292 <col>
33293 <col>
33294 <col>
33295 <col>
33296 </colgroup>
33297 <thead>
33298 <tr id="delta-thead">
33299 <th class="sortable" data-sort-col="path" data-sort-type="str">File<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
33300 <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>
33301 <th class="sortable" data-sort-col="status" data-sort-type="str">Status<span class="sort-icon">↕</span><div class="col-resize-handle"></div></th>
33302 <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>
33303 <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>
33304 <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>
33305 <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>
33306 </tr>
33307 </thead>
33308 <tbody id="delta-tbody">
33309 {% for row in file_rows %}
33310 <tr class="delta-row row-{{ row.status }}" data-status="{{ row.status }}"
33311 data-path="{{ row.relative_path }}"
33312 data-language="{{ row.language }}"
33313 data-baseline-code="{{ row.baseline_code }}"
33314 data-current-code="{{ row.current_code }}"
33315 data-code-delta="{{ row.code_delta_str }}"
33316 data-comment-delta="{{ row.comment_delta_str }}"
33317 data-total-delta="{{ row.total_delta_str }}"
33318 data-orig-idx="">
33319 <td title="{{ row.relative_path }}"><span class="file-path">{{ row.relative_path }}</span></td>
33320 <td class="hide-sm">{{ row.language }}</td>
33321 <td><span class="status-badge {{ row.status }}">{{ row.status }}</span></td>
33322 <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>
33323 <td><span class="delta-val {{ row.code_delta_class }}">{{ row.code_delta_str }}</span></td>
33324 <td class="hide-sm"><span class="delta-val {{ row.comment_delta_class }}">{{ row.comment_delta_str }}</span></td>
33325 <td><span class="delta-val {{ row.total_delta_class }}">{{ row.total_delta_str }}</span></td>
33326 </tr>
33327 {% endfor %}
33328 </tbody>
33329 </table>
33330 </div>
33331 <div class="pagination">
33332 <span class="pagination-info" id="pg-range-label"></span>
33333 <div class="pagination-btns" id="pg-btns"></div>
33334 <div class="flex-row">
33335 <span class="per-page-label">Show</span>
33336 <select class="per-page" id="per-page-sel">
33337 <option value="10">10 per page</option>
33338 <option value="25" selected>25 per page</option>
33339 <option value="50">50 per page</option>
33340 <option value="100">100 per page</option>
33341 </select>
33342 </div>
33343 </div>
33344 </section>
33345 </div>
33346
33347 <div id="ic-tt"></div>
33348
33349 <footer class="site-footer">
33350 local code analysis - metrics, history and reports
33351 · <em class="footer-mode sx-e01b0d98" id="footer-mode" >oxide-sloc v{{ version }} — Mode: Local</em>
33352 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
33353 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
33354 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
33355 · <a href="/report-bug" rel="noopener">Report a Bug</a> · <a href="/api-docs" rel="noopener">REST API</a>
33356 </footer>
33357
33358 <script nonce="{{ csp_nonce }}">
33359 (function () {
33360 var storageKey = 'oxide-sloc-theme';
33361 var body = document.body;
33362 try { var s = localStorage.getItem(storageKey); if (s === 'dark' || s === 'light') body.classList.toggle('dark-theme', s === 'dark'); } catch(e) {}
33363 var toggle = document.getElementById('theme-toggle');
33364 if (toggle) toggle.addEventListener('click', function () {
33365 var next = body.classList.contains('dark-theme') ? 'light' : 'dark';
33366 body.classList.toggle('dark-theme', next === 'dark');
33367 try { localStorage.setItem(storageKey, next); } catch(e) {}
33368 });
33369
33370 (function randomizeWatermarks() {
33371 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
33372 if (!wms.length) return;
33373 var placed = [];
33374 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;}
33375 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];}
33376 var half=Math.floor(wms.length/2);
33377 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;});
33378 })();
33379
33380 (function spawnCodeParticles() {
33381 var container = document.getElementById('code-particles');
33382 if (!container) return;
33383 var snippets = ['NUM sloc','fn analyze()','code_lines','0 mixed','blanks: NUM','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','NUM physical','files: NUM','NUM comments','cargo build','Ok(run)','Vec<GEN>','match lang','fn main()','.rs .go .py','sloc_core','render_html','NUM code','HashMap<GEN, GN2>','Option<GEN>','&str','author AUT','last_change','ratio 0.CPX','complexity CPX','tokens: NUM','?; // try','let owner','commits: NUM','#[tokio::test]','assert_eq!','PCT% cover','blame AUT','churn PCT%','HEAD~CPX','dedup PCT%','cyclomatic CPX','impl Iterator','.await','+NUM -NM2','ownership','Span<GEN>','pub struct','trait Lang','unsafe','mixed: NUM','anyhow::Result','fn detect()','PathBuf','+NUM loc','coverage PCT%','physical: NUM','ULOC NUM','dup PCT%','let mut run']; var __GT=['Author','String','Token','FileRecord','Lang','u32','usize','PathBuf','Commit','Line','Span','Metric','Record','u64','char','Utf8','Ratio','Blame']; var __AN=['nima','core','web','cli','git','lang','ci','bot','sloc'];
33384 for (var i = 0; i < 44; i++) {
33385 (function(idx) {
33386 var el = document.createElement('span');
33387 el.className = 'code-particle';
33388 el.textContent = snippets[idx % snippets.length].split('NUM').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('NM2').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('GEN').join(__GT[Math.floor(Math.random()*__GT.length)]).split('GN2').join(__GT[Math.floor(Math.random()*__GT.length)]).split('AUT').join(__AN[Math.floor(Math.random()*__AN.length)]).split('CPX').join(''+Math.floor(Math.random()*40+1)).split('PCT').join(''+Math.floor(Math.random()*44+1));
33389 var left = Math.random() * 94 + 2;
33390 var top = Math.random() * 88 + 6;
33391 var dur = (Math.random() * 10 + 9).toFixed(1);
33392 var delay = (Math.random() * 18).toFixed(1);
33393 var rot = (Math.random() * 26 - 13).toFixed(1);
33394 var op = (Math.random() * 0.108 + 0.072).toFixed(3);
33395 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';
33396 container.appendChild(el);
33397 })(i);
33398 }
33399 })();
33400 })();
33401
33402 var activeStatusFilter = 'all';
33403 var deltaPerPage = 25, deltaCurrPage = 1;
33404
33405 function openFolder(path) {
33406 fetch('/open-path?path=' + encodeURIComponent(path))
33407 .then(function (r) { return r.json(); })
33408 .then(function (d) {
33409 if (d && d.server_mode_disabled) window.alert(d.message || 'Opening paths in a file manager is only available in local desktop mode.');
33410 })
33411 .catch(function () {});
33412 }
33413
33414 // \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
33415 // The server renders every row once; we lift them into a plain-data array and
33416 // then clear the DOM so only the visible page's <tr>s ever exist. Sorting and
33417 // filtering run on the array (no DOM churn) and each render rebuilds just one
33418 // page (~25 rows). This keeps every interaction O(page) instead of O(all
33419 // files): a 28k-row table previously re-touched every node on each click
33420 // (querySelectorAll x2, appendChild x28k to sort) and froze the page.
33421 var DELTA = [], _deltaView = [], sortCol = null, sortOrder = 'asc';
33422
33423 function parseDeltaNum(str) {
33424 if (!str || str === '\u2014') return 0;
33425 return parseFloat(str.replace(/[^0-9.\-]/g, '')) * (str.trim().charAt(0) === '-' ? -1 : 1);
33426 }
33427
33428 function captureDelta() {
33429 var tbody = document.getElementById('delta-tbody');
33430 if (!tbody) return;
33431 var rows = tbody.querySelectorAll('.delta-row');
33432 for (var i = 0; i < rows.length; i++) {
33433 var r = rows[i];
33434 DELTA.push({
33435 h: r.innerHTML,
33436 cls: r.className,
33437 path: r.getAttribute('data-path') || '',
33438 lang: r.getAttribute('data-language') || '',
33439 status: r.getAttribute('data-status') || '',
33440 bc: parseFloat(r.getAttribute('data-baseline-code')) || 0,
33441 cc: parseFloat(r.getAttribute('data-current-code')) || 0,
33442 cd: parseDeltaNum(r.getAttribute('data-code-delta')),
33443 cmd: parseDeltaNum(r.getAttribute('data-comment-delta')),
33444 td: parseDeltaNum(r.getAttribute('data-total-delta')),
33445 bcs: r.getAttribute('data-baseline-code') || '',
33446 ccs: r.getAttribute('data-current-code') || '',
33447 cds: r.getAttribute('data-code-delta') || '',
33448 cmds: r.getAttribute('data-comment-delta') || '',
33449 tds: r.getAttribute('data-total-delta') || ''
33450 });
33451 }
33452 tbody.innerHTML = '';
33453 }
33454
33455 function applyDeltaQuery() {
33456 var v = (activeStatusFilter === 'all') ? DELTA.slice()
33457 : DELTA.filter(function(d) { return d.status === activeStatusFilter; });
33458 if (sortCol) {
33459 var asc = sortOrder === 'asc';
33460 v.sort(function(a, b) {
33461 var va, vb;
33462 if (sortCol === 'path') { va = a.path; vb = b.path; }
33463 else if (sortCol === 'language') { va = a.lang; vb = b.lang; }
33464 else if (sortCol === 'status') { va = a.status; vb = b.status; }
33465 else if (sortCol === 'baseline_code') { return asc ? a.bc - b.bc : b.bc - a.bc; }
33466 else if (sortCol === 'code_delta') { return asc ? a.cd - b.cd : b.cd - a.cd; }
33467 else if (sortCol === 'comment_delta') { return asc ? a.cmd - b.cmd : b.cmd - a.cmd; }
33468 else if (sortCol === 'total_delta') { return asc ? a.td - b.td : b.td - a.td; }
33469 else { return 0; }
33470 if (asc) return va < vb ? -1 : va > vb ? 1 : 0;
33471 return va < vb ? 1 : va > vb ? -1 : 0;
33472 });
33473 }
33474 _deltaView = v;
33475 deltaCurrPage = 1;
33476 renderDeltaPage();
33477 }
33478
33479 function renderDeltaPage() {
33480 var total = _deltaView.length;
33481 var totalPages = Math.max(1, Math.ceil(total / deltaPerPage));
33482 if (deltaCurrPage > totalPages) deltaCurrPage = totalPages;
33483 if (deltaCurrPage < 1) deltaCurrPage = 1;
33484 var start = (deltaCurrPage - 1) * deltaPerPage;
33485 var end = Math.min(start + deltaPerPage, total);
33486 var tbody = document.getElementById('delta-tbody');
33487 if (tbody) {
33488 var html = '';
33489 for (var i = start; i < end; i++) { var d = _deltaView[i]; html += '<tr class="' + d.cls + '">' + d.h + '</tr>'; }
33490 tbody.innerHTML = html;
33491 }
33492 var rl = document.getElementById('pg-range-label');
33493 if (rl) rl.textContent = total ? 'Showing ' + (start + 1) + '\u2013' + end + ' of ' + total + ' files' : 'No results';
33494 var btns = document.getElementById('pg-btns');
33495 if (!btns) return;
33496 btns.innerHTML = '';
33497 if (totalPages <= 1) return;
33498 function makeBtn(lbl, pg, active, disabled) {
33499 var b = document.createElement('button');
33500 b.className = 'pg-btn' + (active ? ' active' : '');
33501 b.textContent = lbl; b.disabled = disabled;
33502 if (!disabled) b.addEventListener('click', function() { deltaCurrPage = pg; renderDeltaPage(); });
33503 return b;
33504 }
33505 btns.appendChild(makeBtn('\u2039', deltaCurrPage - 1, false, deltaCurrPage === 1));
33506 var ws = Math.max(1, deltaCurrPage - 2), we = Math.min(totalPages, ws + 4); ws = Math.max(1, we - 4);
33507 for (var p = ws; p <= we; p++) btns.appendChild(makeBtn(String(p), p, p === deltaCurrPage, false));
33508 btns.appendChild(makeBtn('\u203a', deltaCurrPage + 1, false, deltaCurrPage === totalPages));
33509 }
33510
33511 window.setDeltaPerPage = function(v) { deltaPerPage = parseInt(v, 10) || 25; deltaCurrPage = 1; renderDeltaPage(); };
33512
33513 function filterRows(status, btn) {
33514 activeStatusFilter = status;
33515 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function (b) {
33516 b.classList.remove('active');
33517 });
33518 if (btn) btn.classList.add('active');
33519 applyDeltaQuery();
33520 }
33521
33522 // ── Sorting ──────────────────────────────────────────────────────────────
33523 var sortHeaders = Array.prototype.slice.call(document.querySelectorAll('#delta-thead .sortable'));
33524 sortHeaders.forEach(function(th) {
33525 th.addEventListener('click', function(e) {
33526 if (e.target.classList.contains('col-resize-handle')) return;
33527 var col = th.dataset.sortCol;
33528 if (sortCol === col) { sortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } else { sortCol = col; sortOrder = 'asc'; }
33529 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
33530 th.classList.add('sort-' + sortOrder);
33531 var si = th.querySelector('.sort-icon'); if (si) si.textContent = sortOrder === 'asc' ? '\u2191' : '\u2193';
33532 applyDeltaQuery();
33533 });
33534 });
33535
33536 // ── Column resize ─────────────────────────────────────────────────────────
33537 (function() {
33538 var table = document.getElementById('delta-table');
33539 if (!table) return;
33540 var cols = Array.prototype.slice.call(table.querySelectorAll('col'));
33541 var ths = Array.prototype.slice.call(table.querySelectorAll('#delta-thead th'));
33542 ths.forEach(function(th, i) {
33543 var handle = th.querySelector('.col-resize-handle');
33544 if (!handle || !cols[i]) return;
33545 handle.addEventListener('mousedown', function(e) {
33546 e.stopPropagation(); e.preventDefault();
33547 // Lock every column to its current rendered px width and size the table
33548 // to the column total. With table-layout:fixed + width:100% the table is
33549 // pinned to the container, so widening one <col> only rebalances the rest
33550 // and the drag looks inert; pinning px widths lets the column actually
33551 // grow while the wrapper (overflow-x:auto) scrolls.
33552 var startTableW = 0;
33553 for (var k = 0; k < ths.length; k++) {
33554 if (!cols[k]) continue;
33555 var w = ths[k].getBoundingClientRect().width;
33556 cols[k].style.width = w + 'px';
33557 startTableW += w;
33558 }
33559 table.style.width = startTableW + 'px';
33560 var startX = e.clientX;
33561 var startW = ths[i].getBoundingClientRect().width;
33562 handle.classList.add('dragging');
33563 function onMove(ev) {
33564 var newW = Math.max(40, startW + ev.clientX - startX);
33565 cols[i].style.width = newW + 'px';
33566 table.style.width = (startTableW + (newW - startW)) + 'px';
33567 }
33568 function onUp() { handle.classList.remove('dragging'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
33569 document.addEventListener('mousemove', onMove);
33570 document.addEventListener('mouseup', onUp);
33571 });
33572 });
33573 })();
33574
33575 // ── Reset ─────────────────────────────────────────────────────────────────
33576 window.resetDeltaTable = function() {
33577 sortCol = null; sortOrder = 'asc';
33578 sortHeaders.forEach(function(t) { var si = t.querySelector('.sort-icon'); if (si) si.textContent = '\u2195'; t.classList.remove('sort-asc', 'sort-desc'); });
33579 var table = document.getElementById('delta-table');
33580 if (table) { table.style.width = ''; Array.prototype.slice.call(table.querySelectorAll('col')).forEach(function(c) { c.style.width = ''; }); }
33581 var pps = document.getElementById('per-page-sel'); if (pps) { pps.value = '25'; deltaPerPage = 25; }
33582 activeStatusFilter = 'all';
33583 Array.prototype.slice.call(document.querySelectorAll('.tab-btn')).forEach(function(b) { b.classList.remove('active'); });
33584 var allBtn = document.querySelector('.tab-btn');
33585 if (allBtn) allBtn.classList.add('active');
33586 applyDeltaQuery();
33587 };
33588
33589 // Compact number formatter (shared by the delta table; charts define their own locally)
33590 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();}
33591 function fmtFull(n){return Number(n).toLocaleString();}
33592
33593 // Format from-to numbers with fmt() and ensure zero→dash for added/removed
33594 function fmtFromTo() {
33595 var tbody = document.getElementById('delta-tbody');
33596 if (!tbody) return;
33597 tbody.querySelectorAll('.delta-row').forEach(function(row) {
33598 var status = row.dataset.status || '';
33599 var ft = row.querySelector('.from-to');
33600 if (!ft) return;
33601 var bv = parseInt(ft.getAttribute('data-baseline') || '0', 10);
33602 var cv = parseInt(ft.getAttribute('data-current') || '0', 10);
33603 var strongs = ft.querySelectorAll('strong');
33604 // Apply fmt() to non-absent strong values
33605 strongs.forEach(function(el) {
33606 var n = parseInt(el.textContent, 10);
33607 if (!isNaN(n)) el.textContent = fmtFull(n);
33608 });
33609 // Safety: force dash for genuinely absent sides
33610 if (status === 'added' && bv === 0) {
33611 var bs = ft.querySelector('strong:first-of-type');
33612 if (bs && bs.textContent === '0') {
33613 bs.outerHTML = '<span class="ft-absent">\u2014</span>';
33614 }
33615 }
33616 if (status === 'removed' && cv === 0) {
33617 var cs = ft.querySelector('strong:last-of-type');
33618 if (cs && cs.textContent === '0') {
33619 cs.outerHTML = '<span class="ft-absent">\u2014</span>';
33620 }
33621 }
33622 });
33623 }
33624 // Initialize: format the server-rendered rows, lift them into the data model
33625 // (which also clears the DOM), then render only the first page.
33626 fmtFromTo();
33627 captureDelta();
33628 applyDeltaQuery();
33629
33630 // ── Event wiring (CSP-safe: no inline handlers) ───────────────────────────
33631 (function() {
33632 Array.prototype.slice.call(document.querySelectorAll('.tab-btn[data-filter]')).forEach(function(btn) {
33633 btn.addEventListener('click', function() { filterRows(btn.dataset.filter, btn); });
33634 });
33635 var resetBtn = document.getElementById('delta-reset-btn');
33636 if (resetBtn) resetBtn.addEventListener('click', function() { window.resetDeltaTable(); });
33637 var csvBtn = document.getElementById('delta-csv-btn');
33638 if (csvBtn) csvBtn.addEventListener('click', function() { window.exportDeltaCsv(); });
33639 var xlsBtn = document.getElementById('delta-xls-btn');
33640 if (xlsBtn) xlsBtn.addEventListener('click', function() { window.exportDeltaXls(); });
33641 // ── Export helpers (image-inlining + pdf-mode) ────────────────────────────
33642 function sdFetchUri(path) {
33643 return fetch(path).then(function(r){return r.blob();}).then(function(b){
33644 return new Promise(function(res){var rd=new FileReader();rd.onload=function(){res(rd.result);};rd.onerror=function(){res('');};rd.readAsDataURL(b);});
33645 }).catch(function(){return '';});
33646 }
33647 function sdInlineImgs(html, cb) {
33648 var paths=[], seen={};
33649 html.replace(/src="(\/images\/[^"]+)"/g,function(_,p){if(!seen[p]){seen[p]=1;paths.push(p);}return _;});
33650 if(!paths.length){cb(html);return;}
33651 Promise.all(paths.map(function(p){return sdFetchUri(p).then(function(u){return{p:p,u:u};});}))
33652 .then(function(rs){rs.forEach(function(r){if(r.u)html=html.split('src="'+r.p+'"').join('src="'+r.u+'"');});cb(html);})
33653 .catch(function(){cb(html);});
33654 }
33655 function buildFullPageHtml(pdfMode) {
33656 if(pdfMode) document.body.classList.add('pdf-mode');
33657 var saved = deltaPerPage; deltaPerPage = 999999; deltaCurrPage = 1;
33658 renderDeltaPage();
33659 var html = window.sxSelfContain(document.documentElement.outerHTML);
33660 deltaPerPage = saved; deltaCurrPage = 1; renderDeltaPage();
33661 if(pdfMode) document.body.classList.remove('pdf-mode');
33662 return html;
33663 }
33664 var chartsBtn = document.getElementById('delta-charts-btn');
33665 if (chartsBtn) chartsBtn.addEventListener('click', function() {
33666 var btn=chartsBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
33667 sdInlineImgs(buildFullPageHtml(false), function(html) {
33668 var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
33669 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
33670 a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
33671 btn.disabled=false;btn.innerHTML=orig;
33672 });
33673 });
33674 var pageHtmlBtn = document.getElementById('page-export-html-btn');
33675 if (pageHtmlBtn) pageHtmlBtn.addEventListener('click', function() {
33676 var btn=pageHtmlBtn,orig=btn.innerHTML;btn.disabled=true;btn.textContent='Exporting\u2026';
33677 sdInlineImgs(buildFullPageHtml(false), function(html) {
33678 var blob=new Blob([html],{type:'text/html;charset=utf-8;'});
33679 var a=document.createElement('a');a.href=URL.createObjectURL(blob);
33680 a.download=getExportFilename('html');a.click();setTimeout(function(){URL.revokeObjectURL(a.href);},200);
33681 btn.disabled=false;btn.innerHTML=orig;
33682 });
33683 });
33684 // PDF export — clean document-style report, not a web page screenshot
33685 function buildDeltaPdfHtml() {
33686 var sd=_sd, dr=getDeltaExportRows();
33687 var dchg=dr.filter(function(r){return (r[2]||'')!=='unchanged';});
33688 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+'%';}
33689 function pcls(b,c){var v=Number(c)-Number(b);return v>0?'pos':(v<0?'neg':'zero');}
33690 var projEl=document.querySelector('[data-folder]'), proj=projEl?projEl.getAttribute('data-folder'):'';
33691 var projName=proj?(String(proj).replace(/[\\/]+$/,'').split(/[\\/]/).pop()||proj):proj;
33692 var tz;try{tz=localStorage.getItem('sloc-tz')||'America/Los_Angeles';}catch(e){tz='America/Los_Angeles';}
33693 var now=(window.fmtTz?window.fmtTz(Date.now(),tz):new Date().toISOString().replace('T',' ').slice(0,16)+' UTC');
33694 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
33695 function fmtN(n){return Number(n).toLocaleString();}
33696 function fullN(n){var v=Number(n);return isNaN(v)?'\u2014':v.toLocaleString();}
33697 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 class="sx-4e307fd5" >'+esc(s)+'</span>':'<span class="sx-e46b3d3d" >'+esc(s)+'</span>';}
33698 var lm={};
33699 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;});
33700 var langs=Object.keys(lm).sort(function(a,b){return lm[b].c-lm[a].c;}).slice(0,15);
33701 var tfTotal=sd.fm+sd.fa+sd.fr+sd.fu;
33702 // The header/footer flow in normal document order (NOT position:fixed).
33703 // A fixed header repeats on every printed page in Chromium and overlaps
33704 // the content beneath it — silently swallowing the first few table rows of
33705 // pages 2+ and clipping the summary cards on page 1. Letting the header
33706 // flow once at the top and relying on the table's <thead> (which Chromium
33707 // repeats per page) keeps every row visible. `.body` keeps a small inset
33708 // so nothing bleeds to the sheet edge.
33709 var css='body{margin:0;padding:0;font-family:"Helvetica Neue",Arial,sans-serif;background:#fff;color:#111;font-size:13px;}'+
33710 '.pdf-header{-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
33711 '.pdf-footer{margin-top:12px;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
33712 '.page-hdr{background:#fff;border-bottom:2px solid #1a2035;padding:8px 14px;display:flex;align-items:center;justify-content:space-between;gap:10px;}'+
33713 '.ph-brand{font-size:14px;font-weight:900;color:#1a2035;white-space:nowrap;}'+
33714 '.ph-brand em{color:#c45c10;font-style:normal;}'+
33715 '.ph-title{font-size:14px;font-weight:600;color:#555;}'+
33716 '.ph-date{font-size:11px;color:#888;text-align:right;white-space:nowrap;}'+
33717 '.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;}'+
33718 '.ib-name{font-size:13px;font-weight:800;color:#fff;}'+
33719 '.ib-path{font-size:10px;color:#8899aa;margin-top:2px;}'+
33720 '.ib-right{font-size:11px;color:#8899aa;text-align:right;line-height:1.7;}'+
33721 '.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;}'+
33722 '.body{padding:12px 18px 0;}'+
33723 '.sg{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px;}'+
33724 '.sc{border:1px solid #ddd;border-radius:8px;padding:8px 10px;}'+
33725 '.sv{font-size:18px;font-weight:900;color:#c45c10;}'+
33726 '.sl{font-size:10px;font-weight:700;text-transform:uppercase;color:#888;margin-top:3px;letter-spacing:.06em;}'+
33727 '.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;}'+
33728 '.meta>div{flex:1 1 0;}'+
33729 '.ml{color:#888;font-size:10px;text-transform:uppercase;letter-spacing:.06em;}.mv{font-weight:700;margin-top:3px;font-size:15px;}'+
33730 '.sec{margin-bottom:10px;}'+
33731 '.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;}'+
33732 '.pg-rhdr th{background:#0f1420;color:#fff;padding:0;border:none;-webkit-print-color-adjust:exact;print-color-adjust:exact;}'+
33733 '.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;}'+
33734 '.pg-rhdr-in em{color:#c45c10;font-style:normal;}'+
33735 '.pg-rhdr-r{color:#9fb0c8;font-weight:600;text-transform:none;letter-spacing:0;}'+
33736 'table{width:100%;border-collapse:collapse;font-size:12px;}'+
33737 '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;}'+
33738 'td{border-bottom:1px solid #eee;padding:3px 8px;vertical-align:middle;}'+
33739 'tr:nth-child(even) td{background:#faf8f6;}'+
33740 '.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;}'+
33741 '.rfoot-spacer{height:30px!important;border:none!important;padding:0!important;background:#fff!important;}'+
33742 '.msec{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
33743 '.mcard{border:1px solid #ddd;border-radius:8px;padding:8px 11px;}'+
33744 '.mc-l{font-size:9px;font-weight:700;text-transform:uppercase;color:#888;letter-spacing:.05em;}'+
33745 '.mc-v{font-size:17px;font-weight:900;color:#1a2035;margin-top:3px;}'+
33746 '.mc-b{font-size:10px;color:#999;margin-top:2px;}'+
33747 '.mc-p{font-size:11px;font-weight:700;margin-top:2px;}'+
33748 '.mc-p.pos{color:#2a6846;}.mc-p.neg{color:#b23030;}.mc-p.zero{color:#999;}'+
33749 '.fcsec{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-top:8px;margin-bottom:10px;}'+
33750 '.fcc{border:1px solid #e5e0d8;border-radius:8px;padding:8px 11px;display:flex;align-items:center;gap:9px;background:#faf8f6;}'+
33751 '.fcc-n{font-size:18px;font-weight:900;}'+
33752 '.fcc-l{font-size:10px;font-weight:600;color:#666;line-height:1.25;}';
33753 var fileRows=dchg.map(function(r){
33754 var st=r[2]||'',ss=st==='added'?'color:#2a6846;font-weight:700':st==='removed'?'color:#b23030;font-weight:700':'';
33755 return '<tr><td class="sx-a83ce3a2" >'+esc(r[0])+'</td><td>'+esc(r[1])+'</td>'+
33756 '<td style="'+ss+'">'+esc(st)+'</td>'+
33757 '<td class="sx-5f326564" >'+fmtN(r[3])+'</td>'+
33758 '<td class="sx-5f326564" >'+fmtN(r[4])+'</td>'+
33759 '<td class="sx-5f326564" >'+delt(r[5])+'</td></tr>';
33760 }).join('')||'<tr><td class="sx-1daf8e4d" colspan="6" >No file changes between these scans.</td></tr>';
33761 var more='';
33762 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 class="sx-5f326564" >'+fmtN(e.f)+'</td><td class="sx-5f326564" >'+fmtN(e.c)+'</td><td class="sx-5f326564" >'+delt(dv)+'</td></tr>';}).join('');
33763 var extraCards='';
33764 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>';}
33765 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>';}
33766 return '<!DOCTYPE html><html><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta</title><style>'+css+'</style></head><body>'+
33767 '<div class="pdf-header">'+
33768 '<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>'+
33769 '<div class="info-bar"><div><div class="ib-name">'+esc(projName)+'</div><div class="ib-path">'+esc(proj)+'</div></div>'+
33770 '<div class="ib-right">Baseline: '+esc(_blabel)+'<br>Current: '+esc(_clabel)+'</div></div>'+
33771 '</div>'+
33772 '<div class="body">'+
33773 '<div class="sec"><p class="sh">Summary Metrics</p>'+
33774 '<div class="msec">'+
33775 '<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>'+
33776 '<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>'+
33777 '<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>'+
33778 '<div class="mcard"><div class="mc-l">Lines Added</div><div class="mc-v sx-24025da0" >+'+fullN(sd.cla)+'</div><div class="mc-b">New or grown source lines</div></div>'+
33779 '<div class="mcard"><div class="mc-l">Lines Removed</div><div class="mc-v sx-fe2ed38d" >−'+fullN(sd.clr)+'</div><div class="mc-b">Deleted or shrunk source lines</div></div>'+
33780 '<div class="mcard"><div class="mc-l">Churn Rate</div><div class="mc-v sx-45e37f19" >'+esc(String(sd.churn))+'</div><div class="mc-b">(added + removed) ÷ baseline</div></div>'+
33781 extraCards+'</div></div>'+
33782 '<div class="sec"><p class="sh">File Changes</p>'+
33783 '<div class="fcsec">'+
33784 '<div class="fcc"><span class="fcc-n sx-8b3f8a70" >'+fullN(sd.fm)+'</span><span class="fcc-l">Modified</span></div>'+
33785 '<div class="fcc"><span class="fcc-n sx-24025da0" >'+fullN(sd.fa)+'</span><span class="fcc-l">Added</span></div>'+
33786 '<div class="fcc"><span class="fcc-n sx-fe2ed38d" >'+fullN(sd.fr)+'</span><span class="fcc-l">Removed</span></div>'+
33787 '<div class="fcc"><span class="fcc-n sx-e5529f3c" >'+fullN(sd.fu)+'</span><span class="fcc-l">Unchanged (identical code counts)</span></div>'+
33788 '<div class="fcc"><span class="fcc-n sx-45e37f19" >'+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>'+
33789 '</div></div>'+
33790 (langs.length?'<div class="sec"><p class="sh">Language Breakdown</p><table><thead><tr><th>Language</th><th class="sx-5f326564" >Files</th><th class="sx-5f326564" >Code Lines</th><th class="sx-5f326564" >Code \u0394</th></tr></thead><tbody>'+langRows+'</tbody></table></div>':'')+
33791 '<div class="sec">'+
33792 '<table><thead>'+
33793 '<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>'+
33794 '<tr><th>File</th><th>Language</th><th>Status</th>'+
33795 '<th class="sx-5f326564" >Code Before</th><th class="sx-5f326564" >Code After</th><th class="sx-5f326564" >Code \u0394</th>'+
33796 '</tr></thead><tbody>'+fileRows+more+'</tbody><tfoot><tr><td colspan="6" class="rfoot-spacer"></td></tr></tfoot></table></div>'+
33797 '</div>'+
33798 '<div class="rfoot">'+
33799 '<span>oxide-sloc v{{ version }} | AGPL-3.0-or-later</span><span>Scan Delta Report</span>'+
33800 '<span>'+esc(sd.bid)+' → '+esc(sd.cid)+'</span>'+
33801 '</div>'+
33802 '</body></html>';
33803 }
33804 function doDeltaPdf(btn) {
33805 window.slocExportPdf({html:buildDeltaPdfHtml(),filename:getExportFilename('pdf'),button:btn});
33806 }
33807 var pdfBtn = document.getElementById('delta-pdf-btn');
33808 if (pdfBtn) pdfBtn.addEventListener('click', function() { doDeltaPdf(pdfBtn); });
33809 var pagePdfBtn = document.getElementById('page-export-pdf-btn');
33810 if (pagePdfBtn) pagePdfBtn.addEventListener('click', function() { doDeltaPdf(pagePdfBtn); });
33811 if (location.protocol === 'file:') {
33812 [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'; } });
33813 [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'; } });
33814 }
33815 var ppSel = document.getElementById('per-page-sel');
33816 if (ppSel) ppSel.addEventListener('change', function() { window.setDeltaPerPage(this.value); });
33817 var pathLink = document.getElementById('project-path-link');
33818 if (pathLink) pathLink.addEventListener('click', function(e) { e.preventDefault(); openFolder(this.dataset.folder); });
33819 })();
33820
33821 // ── Export helpers ────────────────────────────────────────────────────────
33822 function slocEscXml(v){return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
33823 function slocEscCsv(v){var s=String(v);return(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0)?'"'+s.replace(/"/g,'""')+'"':s;}
33824 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);}
33825 function slocMakeXlsx(fname,sd,dr){
33826 var enc=new TextEncoder();
33827 // CRC-32 table
33828 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;}
33829 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;}
33830 function u2(n){return[n&0xFF,(n>>8)&0xFF];}
33831 function u4(n){return[n&0xFF,(n>>8)&0xFF,(n>>16)&0xFF,(n>>24)&0xFF];}
33832 // Shared string table
33833 var ss=[],si={};
33834 function S(v){v=String(v==null?'':v);if(!(v in si)){si[v]=ss.length;ss.push(v);}return si[v];}
33835 function xe(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
33836 // Worksheet builder — each WS() call gets its own row counter R
33837 function WS(){
33838 var R=0,buf=[];
33839 function cl(c){return String.fromCharCode(65+c);}
33840 function sc(c,v,st){return'<c r="'+cl(c)+(R+1)+'" t="s"'+(st?' s="'+st+'"':'')+'>'+
33841 '<v>'+S(v)+'</v></c>';}
33842 function nc(c,v,st){return(v===''||v==null)?'':'<c r="'+cl(c)+(R+1)+'"'+
33843 (st?' s="'+st+'"':'')+'>'+
33844 '<v>'+(+v)+'</v></c>';}
33845 function row(cells){if(cells)buf.push('<row r="'+(R+1)+'">'+cells+'</row>');R++;}
33846 function xml(cw){return'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
33847 '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'+
33848 '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'+
33849 '<sheetFormatPr defaultRowHeight="15"/>'+
33850 (cw?'<cols>'+cw+'</cols>':'')+'<sheetData>'+buf.join('')+'</sheetData></worksheet>';}
33851 return{sc:sc,nc:nc,row:row,xml:xml};
33852 }
33853 // Language breakdown
33854 var lm={};
33855 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;});
33856 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);});
33857 var elp=document.querySelector('[data-folder]'),proj=elp?elp.getAttribute('data-folder'):'';
33858 // Styles: 0=dflt 1=title 2=sub 3=hdr 4=num(#,##0) 5=pos 6=neg 7=zer 8=sectHdr
33859 function dstyle(v){var s=String(v);if(!s||s==='0'||s==='+0')return 7;return s.charAt(0)==='-'?6:5;}
33860 function _sp(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
33861 function _tp(n){var tf=sd.fm+sd.fa+sd.fr+sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
33862 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):'';}
33863 function _ps(p){if(!p)return 0;if(p==='0.0%')return 7;if(p==='new')return 5;return p.charAt(0)==='-'?6:5;}
33864 // Summary sheet
33865 var W1=WS(),s1=W1.sc,n1=W1.nc,r1=W1.row;
33866 r1(s1(0,'OxideSLOC \u2014 Scan Delta Report',1));
33867 r1(s1(0,proj,2));
33868 r1(s1(0,sd.bts+' \u2192 '+sd.cts,2));
33869 r1('');
33870 r1(s1(0,'Metric',3)+s1(1,_blabel,3)+s1(2,_clabel,3)+s1(3,'Delta',3)+s1(4,'% Change',3));
33871 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))));
33872 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))));
33873 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))));
33874 r1('');
33875 r1(s1(0,'FILE CHANGES',8));
33876 r1(s1(0,'Category',3)+s1(3,'Count',3)+s1(4,'% of Total',3));
33877 r1(s1(0,'Modified')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fm,4)+s1(4,_tp(sd.fm)));
33878 r1(s1(0,'Added')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fa,4)+s1(4,_tp(sd.fa)));
33879 r1(s1(0,'Removed')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fr,4)+s1(4,_tp(sd.fr)));
33880 r1(s1(0,'Unchanged')+n1(1,0,4)+n1(2,0,4)+n1(3,sd.fu,4)+s1(4,_tp(sd.fu)));
33881 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)));
33882 if(langs.length){
33883 r1('');r1(s1(0,'LANGUAGE BREAKDOWN',8));
33884 r1(s1(0,'Language',3)+s1(1,'Files Changed',3)+s1(2,'Code Delta',3));
33885 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)));});
33886 }
33887 r1('');r1(s1(0,'SCAN METADATA',8));
33888 r1(s1(1,_blabel)+s1(2,_clabel));
33889 r1(s1(0,'Run ID')+s1(1,sd.bid)+s1(2,sd.cid));
33890 r1(s1(0,'Timestamp')+s1(1,sd.bts)+s1(2,sd.cts));
33891 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"/>');
33892 // File Delta sheet
33893 var W2=WS(),s2=W2.sc,n2=W2.nc,r2=W2.row;
33894 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));
33895 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)));});
33896 var sh2=W2.xml('<col min="1" max="1" width="42" customWidth="1"/><col min="2" max="9" width="13" customWidth="1"/>');
33897 // Shared strings XML
33898 var ssXml='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'+
33899 '<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="'+ss.length+'" uniqueCount="'+ss.length+'">'+
33900 ss.map(function(v){return'<si><t xml:space="preserve">'+xe(v)+'</t></si>';}).join('')+'</sst>';
33901 // XLSX file map
33902 var ox='http://schemas.openxmlformats.org/',pns=ox+'package/2006/',ons=ox+'officeDocument/2006/',sns=ox+'spreadsheetml/2006/main';
33903 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>',
33904 '_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>',
33905 '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>',
33906 '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>',
33907 '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>',
33908 'xl/sharedStrings.xml':ssXml,'xl/worksheets/sheet1.xml':sh1,'xl/worksheets/sheet2.xml':sh2};
33909 // ZIP packer — STORED (no compression), compatible with all XLSX readers
33910 var zparts=[],zcds=[],zoff=0,znf=0;
33911 ['[Content_Types].xml','_rels/.rels','xl/workbook.xml','xl/_rels/workbook.xml.rels',
33912 'xl/styles.xml','xl/sharedStrings.xml','xl/worksheets/sheet1.xml','xl/worksheets/sheet2.xml'
33913 ].forEach(function(name){
33914 var nb=enc.encode(name),db=enc.encode(F[name]),sz=db.length,cr=crc32(db);
33915 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]);
33916 var entry=new Uint8Array(lha.length+nb.length+sz);
33917 entry.set(new Uint8Array(lha),0);entry.set(nb,lha.length);entry.set(db,lha.length+nb.length);
33918 zparts.push(entry);
33919 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));
33920 var cde=new Uint8Array(cda.length+nb.length);
33921 cde.set(new Uint8Array(cda),0);cde.set(nb,cda.length);
33922 zcds.push(cde);zoff+=entry.length;znf++;
33923 });
33924 var cdSz=zcds.reduce(function(a,c){return a+c.length;},0);
33925 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]);
33926 var totSz=zoff+cdSz+ea.length,zout=new Uint8Array(totSz),zpos=0;
33927 zparts.forEach(function(p){zout.set(p,zpos);zpos+=p.length;});
33928 zcds.forEach(function(c){zout.set(c,zpos);zpos+=c.length;});
33929 zout.set(new Uint8Array(ea),zpos);
33930 var xblob=new Blob([zout],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});
33931 var xurl=URL.createObjectURL(xblob);
33932 var xa=document.createElement('a');xa.href=xurl;xa.download=fname;
33933 document.body.appendChild(xa);xa.click();document.body.removeChild(xa);
33934 setTimeout(function(){URL.revokeObjectURL(xurl);},200);
33935 }
33936 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;');}
33937 var _exportBase='{{ project_label }}_{{ baseline_run_id_short }}_vs_{{ current_run_id_short }}';
33938 function getExportFilename(ext){return _exportBase+'.'+ext;}
33939
33940 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 }}'};
33941 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;}
33942 var _blabel=_mkScanLabel('Baseline',_sd.btag,_sd.bbr,_sd.bsha);
33943 var _clabel=_mkScanLabel('Current',_sd.ctag,_sd.cbr,_sd.csha);
33944 function _slPct(num,den){if(!den||den===0)return'';var v=(num/den)*100;return(v>0?'+':'')+v.toFixed(1)+'%';}
33945 function _tfPct(n){var tf=_sd.fm+_sd.fa+_sd.fr+_sd.fu;return tf>0?(n/tf*100).toFixed(1)+'%':'';}
33946 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):'';}
33947 var _summaryHdrs = ['Metric',_blabel,_clabel,'Delta','% Change'];
33948 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)]];}
33949 var _dh = ['File','Language','Status','Code Before ('+_blabel+')','Code After ('+_clabel+')','Code Delta','Comment Delta','Total Delta','% Code Chg'];
33950 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)];});}
33951 window.exportDeltaCsv = function(){slocCsv(_exportBase+'.csv',_dh,getDeltaExportRows());};
33952 window.exportDeltaXls = function(){slocMakeXlsx(getExportFilename('xlsx'),_sd,getDeltaExportRows());};
33953
33954 // ── Chart HTML report ─────────────────────────────────────────────────────
33955 function slocChartReport(fname, sd, dr) {
33956 var OX='#C45C10', GN='#2A6846', RD='#B23030', GY='#AAAAAA', LGY='#DDDDDD';
33957 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
33958 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
33959 function fmt(n){return Number(n).toLocaleString();}
33960 function px(n){return Math.round(n);}
33961 var el=document.querySelector('[data-folder]'), proj=el?el.getAttribute('data-folder'):'';
33962 // Language map
33963 var lm={};
33964 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;});
33965 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
33966
33967 // Builds onmouse* attrs for interactive tooltip on each SVG element
33968 function barTT(label,val){
33969 return ' onmouseover="oxTT(event,\''+jsq(label)+'\',\''+jsq(val)+'\')" onmouseout="oxHT()" onmousemove="oxMT(event)"';
33970 }
33971
33972 // ── Chart 1: Baseline vs Current grouped bars (height fills the card to
33973 // match the Language Code Delta column height) ────────────
33974 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'}];
33975 var FONT_C="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif";
33976 var C1W=600,c1mt=36,c1mb=30,c1ml=14,c1mr=14,c1bw=56,c1gap=10,C1H=380;
33977 var c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length;
33978 var c1='<svg viewBox="0 0 '+C1W+' '+C1H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
33979 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"/>';}
33980 c1+='<line x1="'+c1ml+'" y1="'+(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+(c1mt+c1ph)+'" stroke="#CCC" stroke-width="1.5"/>';
33981 c1mets.forEach(function(m,i){
33982 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
33983 // Per-metric scale so small magnitudes (files) stay visible next to large ones (code).
33984 var gMax=Math.max(m.b,m.c)*1.15||1;
33985 var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
33986 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>';
33987 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))+'/>';
33988 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>';
33989 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))+'/>';
33990 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>';
33991 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>';
33992 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>';
33993 });
33994 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>';
33995 c1+='</svg>';
33996
33997 // ── Chart 2: Delta by Metric ─────────────────────────────────────────
33998 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'}];
33999 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
34000 var C2W=530,rH=56,C2H=mets.length*rH+28,c2LW=144,c2RP=18;
34001 var cx2=c2LW+Math.floor((C2W-c2LW-c2RP)/2),maxBW=Math.floor((C2W-c2LW-c2RP)/2)-4;
34002 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
34003 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
34004 mets.forEach(function(m,i){
34005 var y=16+i*rH,bw=Math.max(Math.abs(m.v)/maxD*maxBW,2);
34006 var col=m.v>=0?GN:RD,bx=m.v>=0?cx2:cx2-bw;
34007 var sign=m.v>=0?'+':'',vStr=sign+fmt(m.v);
34008 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>';
34009 c2+='<rect class="cb" x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"'+barTT(m.l,'Delta: '+vStr)+'/>';
34010 if(bw>=52){
34011 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>';
34012 }else{
34013 var vx2=m.v>=0?px(bx+bw)+5:px(bx)-5,anc2=m.v>=0?'start':'end';
34014 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>';
34015 }
34016 });
34017 c2+='</svg>';
34018
34019 // ── Chart 3: Language Code Delta ─────────────────────────────────────
34020 var c3='';
34021 if(langs.length){
34022 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
34023 var C3W=550,c3LW=124,c3FW=52;
34024 var cx3=c3LW+Math.floor((C3W-c3LW-c3FW-14)/2),maxLBW=Math.floor((C3W-c3LW-c3FW-14)/2)-4;
34025 var L3rH=30,C3H=langs.length*L3rH+20;
34026 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
34027 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
34028 langs.forEach(function(l,i){
34029 var e=lm[l],y=8+i*L3rH,bw=Math.max(Math.abs(e.d)/maxLD*maxLBW,2);
34030 var col=e.d>=0?GN:RD,bx=e.d>=0?cx3:cx3-bw;
34031 var sign=e.d>=0?'+':'',vStr=sign+fmt(e.d);
34032 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>';
34033 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':''))+'/>';
34034 if(bw>=48){
34035 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>';
34036 }else{
34037 var vx3=e.d>=0?px(bx+bw)+4:px(bx)-4,anc3=e.d>=0?'start':'end';
34038 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>';
34039 }
34040 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>';
34041 });
34042 c3+='</svg>';
34043 }
34044
34045 // ── Chart 4: File Change Donut — centered pie with legend below
34046 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;});
34047 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
34048 var C4W=240,Ro=75,Ri=48,cx4=120,cy4=88,legY=172,legRowH=18,C4H=legY+Math.ceil(segs.length/2)*legRowH+8;
34049 var c4='<svg class="sx-f5cb162e" viewBox="0 0 '+C4W+' '+C4H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
34050 var ang=-Math.PI/2;
34051 segs.forEach(function(s){
34052 var sw=Math.min(s.v/tot*2*Math.PI,2*Math.PI-0.001),a2=ang+sw;
34053 var x1=cx4+Ro*Math.cos(ang),y1=cy4+Ro*Math.sin(ang);
34054 var x2=cx4+Ro*Math.cos(a2),y2=cy4+Ro*Math.sin(a2);
34055 var xi1=cx4+Ri*Math.cos(a2),yi1=cy4+Ri*Math.sin(a2);
34056 var xi2=cx4+Ri*Math.cos(ang),yi2=cy4+Ri*Math.sin(ang);
34057 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)+'%')+'/>';
34058 ang+=sw;
34059 });
34060 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>';
34061 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>';
34062 segs.forEach(function(s,i){
34063 var col=i%2===0?14:C4W/2+6,row=Math.floor(i/2);
34064 c4+='<rect x="'+col+'" y="'+(legY+row*legRowH)+'" width="12" height="12" fill="'+s.c+'" rx="2"/>';
34065 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>';
34066 });
34067 c4+='</svg>';
34068
34069 // ── Embedded tooltip JS for the downloaded HTML ───────────────────────
34070 var ttJs='var tt=document.getElementById("ox-tt");'+
34071 'function oxTT(e,t,v){tt.innerHTML="<strong>"+t+"<\/strong><br>"+v;tt.style.display="block";oxMT(e);}'+
34072 'function oxMT(e){var x=e.clientX+16,y=e.clientY-10,r=tt.getBoundingClientRect();'+
34073 'if(x+r.width>window.innerWidth-8)x=e.clientX-r.width-8;'+
34074 'if(y+r.height>window.innerHeight-8)y=e.clientY-r.height-8;'+
34075 'tt.style.left=x+"px";tt.style.top=y+"px";}'+
34076 'function oxHT(){tt.style.display="none";}';
34077
34078 // body max-width keeps charts from inflating beyond design dimensions on
34079 // wide (≥1920 px) monitors — without it SVGs scale to ~950 px wide and
34080 // each chart's height blows up proportionally, breaking the one-page layout.
34081 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;}'+
34082 'h1{color:#C45C10;font-size:21px;margin:0 0 3px;font-weight:800;}p.sub{color:#888;font-size:12px;margin:0 0 18px;}'+
34083 '.card{background:#fff;border-radius:12px;padding:16px 20px;margin-bottom:0;box-shadow:0 1px 5px rgba(0,0,0,.08);}'+
34084 'h2{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#AAA;margin:0 0 10px;}'+
34085 '.leg{display:flex;gap:14px;margin-bottom:10px;font-size:11px;align-items:center;}'+
34086 '.dot{display:inline-block;width:10px;height:10px;border-radius:2px;vertical-align:middle;margin-right:4px;}'+
34087 'svg{display:block;}'+
34088 '.two-col{display:flex;gap:18px;margin-bottom:16px;}.two-col>.card{flex:1;min-width:0;}'+
34089 '#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;}'+
34090 '.cb{cursor:pointer;transition:opacity .15s,filter .15s;}.cb:hover{opacity:.72;filter:brightness(1.1);}';
34091 var html='<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'+
34092 '<title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
34093 '<div id="ox-tt"><\/div>'+
34094 '<h1>OxideSLOC — Scan Delta Charts<\/h1>'+
34095 '<p class="sub">'+esc(proj)+' · '+esc(sd.bts)+' → '+esc(sd.cts)+'<\/p>'+
34096 '<div class="two-col">'+
34097 '<div class="card"><h2>Code Metrics — Baseline vs Current<\/h2>'+
34098 '<div class="leg">'+
34099 '<span><span class="dot sx-618fd811" ><\/span><span class="sx-d50d9131" >Code Lines<\/span><\/span>'+
34100 '<span><span class="dot sx-d94e9768" ><\/span><span class="sx-f6800712" >Files<\/span><\/span>'+
34101 '<span><span class="dot sx-38f87134" ><\/span><span class="sx-c64494ae" >Comments<\/span><\/span>'+
34102 '<span class="sx-d1cc41e0" > (faded = before)<\/span><\/div>'+c1+'<\/div>'+
34103 (langs.length?'<div class="card"><h2>Language Code Delta<\/h2>'+c3+'<\/div>':'<div><\/div>')+
34104 '<\/div>'+
34105 '<div class="two-col">'+
34106 '<div class="card"><h2>Delta by Metric<\/h2>'+c2+'<\/div>'+
34107 '<div class="card"><h2>File Change Distribution<\/h2>'+c4+'<\/div>'+
34108 '<\/div>'+
34109 '<script>'+ttJs+'<\/script>'+
34110 '<\/body><\/html>';
34111 slocDownload(html, fname, 'text/html;charset=utf-8;');
34112 }
34113 window.exportDeltaCharts = function(){slocChartReport(getExportFilename('html'),_sd,getDeltaExportRows());};
34114 window.buildDeltaChartsHtml = function() {
34115 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
34116 var sd=_sd;
34117 var projEl=document.querySelector('[data-folder]');
34118 var proj=projEl?projEl.getAttribute('data-folder'):'';
34119 var c1h=document.getElementById('ic-c1')?document.getElementById('ic-c1').innerHTML:'';
34120 var c2h=document.getElementById('ic-c2')?document.getElementById('ic-c2').innerHTML:'';
34121 var c3h=document.getElementById('ic-c3')?document.getElementById('ic-c3').innerHTML:'';
34122 var c4h=document.getElementById('ic-c4')?document.getElementById('ic-c4').innerHTML:'';
34123 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";}';
34124 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);}';
34125 return '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>OxideSLOC \u2014 Scan Delta Charts<\/title><style>'+css+'<\/style><\/head><body>'+
34126 '<div id="ox-tt"><\/div>'+
34127 '<h1>OxideSLOC \u2014 Scan Delta Charts<\/h1>'+
34128 '<p class="sub">'+esc(proj)+' · '+esc(sd.bts||'')+' \u2192 '+esc(sd.cts||'')+'<\/p>'+
34129 '<div class="two-col">'+
34130 '<div class="card"><h2>Code Metrics \u2014 Baseline vs Current<\/h2>'+
34131 '<div class="leg"><span><span class="dot sx-618fd811" ><\/span><span class="sx-d50d9131" >Code Lines<\/span><\/span>'+
34132 '<span><span class="dot sx-d94e9768" ><\/span><span class="sx-f6800712" >Files<\/span><\/span>'+
34133 '<span><span class="dot sx-38f87134" ><\/span><span class="sx-c64494ae" >Comments<\/span><\/span><\/div>'+c1h+'<\/div>'+
34134 (c3h?'<div class="card"><h2>Language Code Delta<\/h2>'+c3h+'<\/div>':'<div><\/div>')+
34135 '<\/div>'+
34136 '<div class="two-col">'+
34137 '<div class="card"><h2>Delta by Metric<\/h2>'+c2h+'<\/div>'+
34138 '<div class="card"><h2>File Change Distribution<\/h2>'+c4h+'<\/div>'+
34139 '<\/div>'+
34140 '<script>'+ttJs+'<\/script>'+
34141 '<\/body><\/html>';
34142 };
34143 // ── Inline delta charts ────────────────────────────────────────────────────
34144 var _icTT=document.getElementById('ic-tt');
34145 window.icTT=function(e,t,v){if(!_icTT)return;_icTT.innerHTML='<strong>'+t+'</strong><br>'+v;_icTT.style.display='block';window.icMT(e);};
34146 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';};
34147 window.icHT=function(){if(_icTT)_icTT.style.display='none';};
34148 window.addEventListener('blur',function(){window.icHT();});
34149 document.addEventListener('visibilitychange',function(){if(document.hidden)window.icHT();});
34150 (function(){
34151 // Theme-aware palette — matches the canonical scheme used by /test-metrics
34152 // charts so every page renders bars/text/grid with the same colours and
34153 // adapts to dark mode (see Design section in CLAUDE.md).
34154 var cs=getComputedStyle(document.body),dark=document.body.classList.contains('dark-theme');
34155 function cv(n,fb){var v=cs.getPropertyValue(n);return(v&&v.trim())||fb;}
34156 var OX='#C45C10',GN='#2A6846',GD='#D4A017',RD='#B23030';
34157 // Deeper shade of each metric hue for "before"/baseline bars — bold (not
34158 // washed) so the chart reads with the same weight as /test-metrics.
34159 var OXD='#8a3f0a',GND='#1d4a30',GDD='#9c7610';
34160 var FADE=dark?'#524238':'#e6d0bf';
34161 var textCol=cv('--text','#43342d'),mutedCol=cv('--muted','#7b675b'),LGY=cv('--line','#e6d0bf'),axisCol=cv('--line-strong','#d8bfad'),surfCol=cv('--surface','#fbf7f2');
34162 function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
34163 function fmt(n){return Number(n).toLocaleString();}
34164 function px(n){return Math.round(n);}
34165 function jsq(s){return String(s).replace(/\\/g,'\\\\').replace(/'/g,'\\x27');}
34166 function btt(l,v){return ' class="ic-cb" data-ttl="'+esc(l)+'" data-ttv="'+esc(v)+'"';}
34167 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);});}
34168 var dr=getDeltaExportRows(),sd=_sd,lm={};
34169 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;});
34170 var langs=Object.keys(lm).sort(function(a,b){return Math.abs(lm[b].d)-Math.abs(lm[a].d);}).slice(0,12);
34171 // Chart 1: Baseline vs Current grouped bars. Height grows to fill the card so
34172 // the bars are as tall as the (usually taller) Language Code Delta sibling that
34173 // shares the same grid row, instead of sitting short at the top.
34174 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}];
34175 function drawC1(){
34176 var C1W=600,C1H=188;
34177 var host=document.getElementById('ic-c1'),card=host?host.closest('.ic-card'):null;
34178 if(host&&card&&host.clientWidth>0){
34179 var avW=host.clientWidth;
34180 var availPx=(card.getBoundingClientRect().bottom-16)-host.getBoundingClientRect().top;
34181 var wantH=availPx*C1W/avW;
34182 if(wantH>C1H)C1H=wantH;
34183 }
34184 var c1mt=36,c1mb=44,c1ml=14,c1mr=14,c1ph=C1H-c1mt-c1mb,c1gW=(C1W-c1ml-c1mr)/c1mets.length,c1bw=56,c1gap=10;
34185 var c1='<svg viewBox="0 0 '+C1W+' '+px(C1H)+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
34186 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"/>';}
34187 c1+='<line x1="'+c1ml+'" y1="'+px(c1mt+c1ph)+'" x2="'+(C1W-c1mr)+'" y2="'+px(c1mt+c1ph)+'" stroke="'+axisCol+'" stroke-width="1.5"/>';
34188 c1mets.forEach(function(m,i){
34189 var cx=px(c1ml+i*c1gW+c1gW/2),c1x0=px(cx-c1gap/2-c1bw),c1x1=px(cx+c1gap/2);
34190 // Each metric scales to its OWN max so wildly different magnitudes (e.g. 4.5M
34191 // code lines vs 28K files) are all readable — a shared scale buries the small ones.
34192 var gMax=Math.max(m.b,m.c)*1.15||1;
34193 var bh0=Math.max(c1ph*m.b/gMax,2),bh1=Math.max(c1ph*m.c/gMax,2);
34194 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>';
34195 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"/>';
34196 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>';
34197 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"/>';
34198 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>';
34199 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>';
34200 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>';
34201 });
34202 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>';
34203 c1+='</svg>';
34204 return c1;
34205 }
34206 var c1=drawC1();
34207 // Chart 2: Delta by Metric
34208 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}];
34209 var maxD=Math.max.apply(null,mets.map(function(m){return Math.abs(m.v);}))||1;
34210 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;
34211 var c2='<svg viewBox="0 0 '+C2W+' '+C2H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
34212 c2+='<line x1="'+cx2+'" y1="6" x2="'+cx2+'" y2="'+(C2H-6)+'" stroke="'+LGY+'" stroke-width="1.5"/>';
34213 mets.forEach(function(m,i){
34214 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);
34215 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>';
34216 c2+='<rect'+btt(m.l,'Delta: '+vStr)+' x="'+px(bx)+'" y="'+(y+5)+'" width="'+px(bw)+'" height="32" fill="'+col+'" rx="3"/>';
34217 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>';}
34218 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>';}
34219 });
34220 c2+='</svg>';
34221 // Chart 3: Language Code Delta
34222 var c3='';
34223 if(langs.length){
34224 var maxLD=Math.max.apply(null,langs.map(function(l){return Math.abs(lm[l].d);}))||1;
34225 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;
34226 c3='<svg viewBox="0 0 '+C3W+' '+C3H+'" width="100%" xmlns="http://www.w3.org/2000/svg">';
34227 c3+='<line x1="'+cx3+'" y1="0" x2="'+cx3+'" y2="'+C3H+'" stroke="'+LGY+'" stroke-width="1.5"/>';
34228 langs.forEach(function(l,i){
34229 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);
34230 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>';
34231 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"/>';
34232 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>';}
34233 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>';}
34234 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>';
34235 });
34236 c3+='</svg>';
34237 }
34238 // Chart 4: File Change Donut — pie left, legend to the right (vertically centered)
34239 var FONT4='Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif';
34240 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;});
34241 var tot=segs.reduce(function(a,s){return a+s.v;},0)||1;
34242 var DW=395,DH=Math.max(200,segs.length*30+44),cx4=104,cy4=Math.round(DH/2),Ro=88,Ri=48;
34243 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);
34244 var c4='<svg class="sx-1f581618" viewBox="0 0 '+DW+' '+DH+'" width="100%" xmlns="http://www.w3.org/2000/svg">',ang=-Math.PI/2;
34245 if(segs.length===1){
34246 var rm=Math.round((Ro+Ri)/2),rsw=Ro-Ri;
34247 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+'"/>';
34248 } else {
34249 // Give every visible slice a small minimum sweep, taken from the largest
34250 // slice. Without this a ~100% slice (e.g. all-Unchanged) spans a full 360°
34251 // arc whose start and end points coincide, so SVG renders nothing (blank).
34252 var TWO=2*Math.PI,minSw=0.06,raw=segs.map(function(s){return s.v/tot*TWO;}),maxIdx=0;
34253 for(var k=1;k<raw.length;k++){if(raw[k]>raw[maxIdx])maxIdx=k;}
34254 var deficit=0,sweeps=raw.map(function(rw,k){if(k!==maxIdx&&rw<minSw){deficit+=(minSw-rw);return minSw;}return rw;});
34255 sweeps[maxIdx]=Math.max(0.001,sweeps[maxIdx]-deficit);
34256 segs.forEach(function(s,si){
34257 var sw=Math.min(sweeps[si],TWO-0.06),a2=ang+sw;
34258 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);
34259 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);
34260 var pct=Math.round(s.v/tot*100);
34261 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"/>';
34262 if(pct>=5){var mAng=ang+sw/2,mR=(Ro+Ri)/2;c4+='<text class="sx-c3270469" 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')+'" >'+pct+'%</text>';}
34263 ang+=sw;
34264 });
34265 }
34266 c4+='<text x="'+cx4+'" y="'+(cy4-7)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="21" font-weight="800" fill="'+textCol+'">'+fmt(tot)+'</text>';
34267 c4+='<text x="'+cx4+'" y="'+(cy4+14)+'" text-anchor="middle" font-family="'+FONT4+'" font-size="11" fill="'+mutedCol+'">total files</text>';
34268 segs.forEach(function(s,i){
34269 var ly=legYStart+i*legSpacing,pct=Math.round(s.v/tot*100);
34270 c4+='<g class="sx-83ac1cee"'+btt(s.l,fmt(s.v)+' files \u2022 '+pct+'%')+' >';
34271 c4+='<rect x="'+legX+'" y="'+(ly-2)+'" width="'+(DW-legX)+'" height="'+legSpacing+'" fill="transparent"/>';
34272 c4+='<rect x="'+legX+'" y="'+ly+'" width="11" height="11" rx="2" fill="'+s.c+'"/>';
34273 c4+='<text x="'+(legX+16)+'" y="'+(ly+10)+'" font-family="'+FONT4+'" font-size="'+Math.min(13,legSpacing-3)+'" fill="'+textCol+'">'+esc(s.l)+'</text>';
34274 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>';
34275 c4+='</g>';
34276 });
34277 c4+='</svg>';
34278 // Inject the fixed-height siblings first so the grid row settles to the (taller)
34279 // Language Code Delta height, then draw Code Metrics (c1) to fill that height.
34280 var e2=document.getElementById('ic-c2');if(e2){e2.innerHTML=c2;addTT(e2);}
34281 var e3=document.getElementById('ic-c3');if(e3){e3.innerHTML=langs.length?c3:'<p class="sx-90171b6d" >No language delta.</p>';addTT(e3);}
34282 var e4=document.getElementById('ic-c4');if(e4){e4.innerHTML=c4;addTT(e4);}
34283 var lc=document.getElementById('ic-lang-card');if(lc)lc.style.display=langs.length?'':'none';
34284 var e1=document.getElementById('ic-c1');if(e1){e1.innerHTML=drawC1();addTT(e1);}
34285
34286 // Compare Timeline chart (Baseline vs Current, 2 points)
34287 (function() {
34288 var activeCmpMetric='code';
34289 var cmpMetricLabel={code:'Code Lines',files:'Files',comments:'Comments',tests:'Tests',cov:'Coverage'};
34290 function renderCmpTL(metric, targetSvg, targetH) {
34291 var svg=targetSvg||document.getElementById('cmp-tl-svg');if(!svg)return;
34292 var W=svg.getBoundingClientRect().width||800,H=targetH||280;
34293 svg.setAttribute('height',H);
34294 var pad={l:62,r:20,t:32,b:72};
34295 var dark=document.body.classList.contains('dark-theme');
34296 var cmpPts=[
34297 {v:{code:_sd.bc,files:_sd.bf,comments:_sd.bcm,tests:_sd.btests,cov:_sd.bcov},label:(_sd.bsha||'').substring(0,7)||'Base'},
34298 {v:{code:_sd.cc,files:_sd.cf,comments:_sd.ccm,tests:_sd.ctests,cov:_sd.ccov},label:(_sd.csha||'').substring(0,7)||'Curr'}
34299 ];
34300 var pts=cmpPts.map(function(p){var v=p.v[metric];return(v==null)?null:Number(v);});
34301 var valid=pts.filter(function(v){return v!=null;});
34302 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;}
34303 var minV=0,maxV=Math.max.apply(null,valid);
34304 if(maxV<=0){maxV=1;}else{maxV=maxV*1.08;}
34305 var plotW=W-pad.l-pad.r,plotH=H-pad.t-pad.b;
34306 var cx0=pad.l,cx1=pad.l+plotW;
34307 var cy0=pts[0]!=null?pad.t+plotH-(pts[0]-minV)/(maxV-minV)*plotH:pad.t+plotH;
34308 var cy1=pts[1]!=null?pad.t+plotH-(pts[1]-minV)/(maxV-minV)*plotH:pad.t+plotH;
34309 var gridColor=dark?'rgba(255,255,255,0.08)':'rgba(0,0,0,0.07)';
34310 var textColor=dark?'rgba(255,255,255,0.6)':'rgba(67,52,45,0.7)';
34311 var areaColor=dark?'rgba(211,122,76,0.12)':'rgba(211,122,76,0.10)';
34312 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();}
34313 function escH(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
34314 var parts=[];
34315 parts.push('<rect x="0" y="0" width="'+W+'" height="'+H+'" fill="'+(dark?'#241a12':'#fbf7f2')+'" rx="8"/>');
34316 for(var gi=0;gi<5;gi++){
34317 var gy=pad.t+plotH/4*gi,gv=maxV-(maxV-minV)/4*gi;
34318 parts.push('<line x1="'+pad.l+'" y1="'+gy.toFixed(1)+'" x2="'+(W-pad.r)+'" y2="'+gy.toFixed(1)+'" stroke="'+gridColor+'" stroke-width="1"/>');
34319 parts.push('<text x="'+(pad.l-6)+'" y="'+(gy+4).toFixed(1)+'" text-anchor="end" font-size="10" fill="'+textColor+'">'+fmtN(gv)+'</text>');
34320 }
34321 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+'"/>');
34322 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"/>');
34323 var dotPts=[{cx:cx0,cy:cy0,v:pts[0],lbl:cmpPts[0].label,anchor:'start',lbl2:'BASELINE'},
34324 {cx:cx1,cy:cy1,v:pts[1],lbl:cmpPts[1].label,anchor:'end',lbl2:'CURRENT'}];
34325 dotPts.forEach(function(pt){
34326 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>');
34327 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"/>');
34328 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>');
34329 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>');
34330 });
34331 parts.push('<text x="'+(pad.l+plotW/2)+'" y="'+(H-4)+'" text-anchor="middle" font-size="10" fill="'+textColor+'">'+escH(cmpMetricLabel[metric]||metric)+'</text>');
34332 svg.setAttribute('viewBox','0 0 '+W+' '+H);
34333 svg.innerHTML=parts.join('');
34334 // Hover: crosshair + tooltip (matches multi-scan timeline)
34335 var cmpTT=document.getElementById('ic-tt');
34336 svg.onmousemove=function(e){
34337 var rect=svg.getBoundingClientRect();
34338 var scaleX=W/rect.width;
34339 var mouseX=(e.clientX-rect.left)*scaleX;
34340 var nearest=-1,minDist=Infinity;
34341 var cxArr=[cx0,cx1];
34342 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;}}
34343 if(nearest<0)return;
34344 var nc=cxArr[nearest],ny=(nearest===0?cy0:cy1);
34345 var xhair=svg.querySelector('.cmp-xhair');
34346 if(!xhair){xhair=document.createElementNS('http://www.w3.org/2000/svg','g');xhair.setAttribute('class','cmp-xhair');svg.appendChild(xhair);}
34347 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"/>';
34348 if(!cmpTT)return;
34349 var clbl=cmpPts[nearest].label;
34350 var scanLbl=nearest===0?'Baseline':'Current';
34351 cmpTT.innerHTML='<strong>'+scanLbl+'</strong> <span class="sx-0819fd61" >'+escH(clbl)+'</span><br>'+escH(cmpMetricLabel[metric]||metric)+': <strong>'+Number(pts[nearest]).toLocaleString()+'</strong>';
34352 var bx=rect.left+(nc/W*rect.width)+18;
34353 if(bx+220>window.innerWidth-8)bx=rect.left+(nc/W*rect.width)-228;
34354 cmpTT.style.left=bx+'px';cmpTT.style.top=(e.clientY-38)+'px';cmpTT.style.display='block';
34355 };
34356 svg.onmouseleave=function(){
34357 var xhair=svg.querySelector('.cmp-xhair');if(xhair)xhair.innerHTML='';
34358 if(cmpTT)cmpTT.style.display='none';
34359 };
34360 }
34361 document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(btn){
34362 btn.addEventListener('click',function(){
34363 activeCmpMetric=this.dataset.cmpMetric;
34364 document.querySelectorAll('.cmp-tl-btns .chart-metric-btn').forEach(function(b){b.classList.remove('active');});
34365 this.classList.add('active');
34366 renderCmpTL(activeCmpMetric);
34367 });
34368 });
34369 var ttgl=document.getElementById('theme-toggle');
34370 if(ttgl)ttgl.addEventListener('click',function(){setTimeout(function(){renderCmpTL(activeCmpMetric);if(window.__sdFvTL)renderCmpTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);},0);});
34371 if(typeof ResizeObserver!=='undefined'){
34372 var cmpSvg=document.getElementById('cmp-tl-svg');
34373 if(cmpSvg)new ResizeObserver(function(){renderCmpTL(activeCmpMetric);}).observe(cmpSvg);
34374 }
34375 // Expose the timeline renderer + current metric so the Full View modal can
34376 // re-draw it live (pixel-sized chart can't be snapshot-scaled like the bars).
34377 window.__sdRenderTL=function(m,svgEl,h){renderCmpTL(m,svgEl,h);};
34378 window.__sdGetMetric=function(){return activeCmpMetric;};
34379 renderCmpTL(activeCmpMetric);
34380 })();
34381
34382 // HTML legend hover -> highlight matching SVG bars within the SAME card only
34383 document.querySelectorAll('.ic-leg-item[data-highlight]').forEach(function(leg){
34384 var metric=leg.getAttribute('data-highlight');
34385 var parentCard=leg.closest('.ic-card');
34386 var chartEl=parentCard?parentCard.querySelector('[id]'):null;
34387 if(!chartEl)return;
34388 leg.addEventListener('mouseenter',function(){
34389 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){
34390 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';}
34391 else{x.style.opacity='0.28';}
34392 });
34393 });
34394 leg.addEventListener('mouseleave',function(){
34395 chartEl.querySelectorAll('[data-ttl]').forEach(function(x){x.style.filter='';x.style.opacity='';});
34396 });
34397 });
34398
34399 // ── Full View: enlarge any chart in a modal (snapshots current SVG) ──────
34400 (function(){
34401 var ov=document.getElementById('ic-svg-modal-ov');
34402 var body=document.getElementById('ic-svg-modal-body');
34403 var ttl=document.getElementById('ic-svg-modal-title');
34404 var closeBtn=document.getElementById('ic-svg-modal-close');
34405 if(!ov||!body)return;
34406 function close(){
34407 ov.classList.remove('open');body.innerHTML='';
34408 if(window.__sdFvTL){if(window.__sdFvTL.ro)window.__sdFvTL.ro.disconnect();window.__sdFvTL=null;}
34409 var tt=document.getElementById('ic-tt');if(tt)tt.style.display='none';
34410 }
34411 function open(srcId,title){
34412 var src=document.getElementById(srcId);if(!src)return;
34413 if(ttl)ttl.textContent=title||'';
34414 // The Timeline is pixel-sized (viewBox locked to its render width), so a static
34415 // snapshot stretches and loses interactivity. Re-render it live into the modal at
34416 // full size instead — keeps proportions, animation, crosshair, tooltip and the
34417 // metric tabs working exactly like the inline chart.
34418 if(srcId==='cmp-tl-svg'&&window.__sdRenderTL){
34419 var curM=window.__sdGetMetric?window.__sdGetMetric():'code';
34420 var mets=[['code','Code Lines'],['files','Files'],['comments','Comments'],['tests','Tests'],['cov','Coverage']];
34421 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('');
34422 body.innerHTML='<div class="cmp-tl-btns sx-e1b4305c" >'+btnsHtml+'</div><div class="chart-wrap sx-9ccc4ca9" ><svg class="sx-e3ce48e7" id="cmp-tl-fv-svg" width="100%" height="440" ></svg></div>';
34423 var fvSvg=body.querySelector('#cmp-tl-fv-svg');
34424 window.__sdFvTL={svg:fvSvg,h:440,metric:curM,ro:null};
34425 ov.classList.add('open');
34426 requestAnimationFrame(function(){window.__sdRenderTL(window.__sdFvTL.metric,fvSvg,440);});
34427 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;}
34428 body.querySelectorAll('[data-fv-metric]').forEach(function(b){
34429 b.addEventListener('click',function(){
34430 if(!window.__sdFvTL)return;
34431 window.__sdFvTL.metric=this.getAttribute('data-fv-metric');
34432 body.querySelectorAll('[data-fv-metric]').forEach(function(x){x.classList.remove('active');});
34433 this.classList.add('active');
34434 window.__sdRenderTL(window.__sdFvTL.metric,window.__sdFvTL.svg,window.__sdFvTL.h);
34435 });
34436 });
34437 return;
34438 }
34439 var card=src.closest('.ic-card');
34440 var legHtml='';
34441 if(card){var leg=card.querySelector('.ic-leg');if(leg)legHtml='<div class="ic-leg sx-16dbf2a3" >'+leg.innerHTML+'</div>';}
34442 var inner=src.tagName.toLowerCase()==='svg'?src.outerHTML:src.innerHTML;
34443 if(!inner||!inner.replace(/\s/g,'')){body.innerHTML=legHtml+'<p class="sx-90171b6d" >No chart data to display.</p>';ov.classList.add('open');return;}
34444 body.innerHTML=legHtml+inner;
34445 var svg=body.querySelector('svg');
34446 if(svg){svg.removeAttribute('width');svg.removeAttribute('height');svg.style.width='100%';svg.style.height='auto';svg.style.maxWidth='none';}
34447 addTT(body);
34448 ov.classList.add('open');
34449 }
34450 document.querySelectorAll('.ic-expand-btn[data-expand-src]').forEach(function(btn){
34451 btn.addEventListener('click',function(){open(btn.getAttribute('data-expand-src'),btn.getAttribute('data-expand-title'));});
34452 });
34453 if(closeBtn)closeBtn.addEventListener('click',close);
34454 ov.addEventListener('click',function(e){if(e.target===ov)close();});
34455 document.addEventListener('keydown',function(e){if(e.key==='Escape'&&ov.classList.contains('open'))close();});
34456 })();
34457
34458 document.querySelectorAll('.cmp-author-val').forEach(function(el){var h=el.nextElementSibling;if(h)h.textContent='/'+el.textContent.replace(/\s+/g,'');});
34459 })();
34460 </script>
34461 {{ toast_assets|safe }}
34462 <script nonce="{{ csp_nonce }}">
34463 (function(){
34464 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'}];
34465 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);});}
34466 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
34467 function init(){
34468 var btn=document.getElementById('settings-btn');if(!btn)return;
34469 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
34470 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 class="sx-dec3b281" ><div class="settings-modal-label sx-c500155b" >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>';
34471 document.body.appendChild(m);
34472 var g=document.getElementById('scheme-grid');
34473 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);});
34474 var cl=document.getElementById('settings-close');
34475 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);});})();
34476 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');});
34477 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
34478 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
34479 }
34480 if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
34481 }());
34482 </script>
34483 <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]';
34484 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;}
34485 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>
34486</body>
34487</html>
34488"##,
34489 ext = "html"
34490)]
34491#[allow(clippy::struct_excessive_bools)]
34493struct CompareTemplate {
34494 loading_overlay: String,
34496 version: &'static str,
34497 project_label: String,
34498 baseline_git_commit: String,
34499 current_git_commit: String,
34500 baseline_run_id: String,
34501 current_run_id: String,
34502 baseline_run_id_short: String,
34503 current_run_id_short: String,
34504 baseline_timestamp: String,
34505 baseline_timestamp_utc_ms: i64,
34506 current_timestamp: String,
34507 current_timestamp_utc_ms: i64,
34508 project_path: String,
34509 baseline_code: u64,
34510 current_code: u64,
34511 code_lines_delta_str: String,
34512 code_lines_delta_class: String,
34513 baseline_files: u64,
34514 current_files: u64,
34515 files_analyzed_delta_str: String,
34516 files_analyzed_delta_class: String,
34517 baseline_comments: u64,
34518 current_comments: u64,
34519 comment_lines_delta_str: String,
34520 comment_lines_delta_class: String,
34521 baseline_code_fmt: String,
34522 current_code_fmt: String,
34523 baseline_files_fmt: String,
34524 current_files_fmt: String,
34525 baseline_comments_fmt: String,
34526 current_comments_fmt: String,
34527 code_lines_pct_str: String,
34528 files_analyzed_pct_str: String,
34529 comment_lines_pct_str: String,
34530 code_lines_added: i64,
34531 code_lines_removed: i64,
34532 code_lines_modified: i64,
34534 code_lines_unmodified: i64,
34536 code_lines_total: i64,
34538 new_scope: bool,
34540 churn_rate_str: String,
34541 churn_rate_class: String,
34542 scope_flag: bool,
34543 files_added: usize,
34544 files_removed: usize,
34545 files_modified: usize,
34546 files_unchanged: usize,
34547 files_total: usize,
34548 file_rows: Vec<CompareFileDeltaRow>,
34549 baseline_git_author: Option<String>,
34550 current_git_author: Option<String>,
34551 baseline_git_branch: String,
34552 current_git_branch: String,
34553 baseline_performed_by: String,
34554 current_performed_by: String,
34555 baseline_git_tags: Option<String>,
34556 current_git_tags: Option<String>,
34557 baseline_git_commit_date: Option<String>,
34558 current_git_commit_date: Option<String>,
34559 project_name: String,
34560 submodule_options: Vec<String>,
34562 has_any_submodule_data: bool,
34564 active_submodule: Option<String>,
34566 super_scope_active: bool,
34568 csp_nonce: String,
34569 toast_assets: String,
34571 coverage_delta_card: String,
34573 baseline_test_count: u64,
34574 current_test_count: u64,
34575 baseline_coverage_pct: Option<f64>,
34576 current_coverage_pct: Option<f64>,
34577}
34578
34579#[derive(Template)]
34582#[template(
34583 source = r##"
34584<!doctype html>
34585<html lang="en">
34586<head>
34587 <meta charset="utf-8">
34588 <meta name="viewport" content="width=device-width, initial-scale=1">
34589 <title>OxideSLOC | Sign In</title>
34590 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
34591 <link rel="stylesheet" href="/static/app.css">
34592 <script src="/static/app.js"></script>
34593 <style nonce="{{ csp_nonce }}">
34594 :root {
34595 --bg:#f5efe8; --surface:#fbf7f2; --line:#e6d0bf; --line-strong:#d8bfad;
34596 --text:#2f241c; --muted:#7b675b; --nav:#283790; --nav-2:#013e6b;
34597 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 8px 32px rgba(77,44,20,.10);
34598 --err-bg:#fdf0f0; --err-border:#e8b4b4; --err-text:#8b2020;
34599 }
34600 *{box-sizing:border-box;}
34601 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);}
34602 .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);}
34603 .brand{display:flex;align-items:center;gap:12px;text-decoration:none;}
34604 .brand-logo{width:38px;height:42px;object-fit:contain;filter:drop-shadow(0 4px 10px rgba(0,0,0,.22));}
34605 .brand-title{color:#fff;font-size:17px;font-weight:800;margin:0;}
34606 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
34607 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
34608 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
34609 .code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
34610 @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));}}
34611 .page{display:flex;align-items:center;justify-content:center;min-height:calc(100vh - 56px);padding:24px;position:relative;z-index:1;}
34612 .card{background:var(--surface);border:1px solid var(--line);border-radius:16px;padding:40px;max-width:420px;width:100%;box-shadow:var(--shadow);}
34613 h1{margin:0 0 6px;font-size:24px;font-weight:850;letter-spacing:-0.03em;}
34614 .subtitle{color:var(--muted);font-size:14px;margin:0 0 28px;}
34615 .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;}
34616 label{display:block;font-size:13px;font-weight:700;margin-bottom:6px;}
34617 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;}
34618 input[type=password]:focus{border-color:var(--oxide);}
34619 .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;}
34620 .btn:hover{opacity:.88;}
34621 .hint{color:var(--muted);font-size:12px;margin-top:20px;line-height:1.6;}
34622 code{background:#f3e9e0;padding:1px 5px;border-radius:4px;font-size:11px;}
34623 </style>
34624</head>
34625<body>
34626 <div class="background-watermarks" aria-hidden="true">
34627 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
34628 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
34629 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
34630 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
34631 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
34632 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
34633 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
34634 </div>
34635 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
34636<nav class="top-nav">
34637 <a class="brand" href="/">
34638 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC">
34639 <span class="brand-title">OxideSLOC</span>
34640 </a>
34641</nav>
34642<main class="page">
34643 <div class="card">
34644 <h1>Sign In</h1>
34645 <p class="subtitle">Enter the API key printed when the server started.</p>
34646 {% if has_error %}
34647 <div class="error">Incorrect API key — please try again.</div>
34648 {% endif %}
34649 <form method="POST" action="/auth/login">
34650 <input type="hidden" name="next" value="{{ next_url|e }}">
34651 <label for="key">API Key</label>
34652 <input id="key" type="password" name="key" autocomplete="current-password"
34653 placeholder="Paste your API key here" autofocus>
34654 <button type="submit" class="btn">Sign In</button>
34655 </form>
34656 <p class="hint">
34657 The API key was printed in the terminal when the server started.<br>
34658 To skip auth on a trusted LAN: leave <code>SLOC_API_KEY</code> unset.<br>
34659 Note: {{ lockout_threshold }} failed attempts from the same IP triggers a temporary lockout.
34660 </p>
34661 </div>
34662</main>
34663<script nonce="{{ csp_nonce }}">
34664(function() {
34665 (function randomizeWatermarks() {
34666 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
34667 if (!wms.length) return;
34668 var placed = [];
34669 function tooClose(top, left) {
34670 for (var i = 0; i < placed.length; i++) {
34671 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
34672 if (dt < 16 && dl < 12) return true;
34673 }
34674 return false;
34675 }
34676 function pick(leftBand) {
34677 for (var attempt = 0; attempt < 50; attempt++) {
34678 var top = Math.random() * 88 + 2;
34679 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
34680 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
34681 }
34682 var top = Math.random() * 88 + 2;
34683 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
34684 placed.push([top, left]); return [top, left];
34685 }
34686 var half = Math.floor(wms.length / 2);
34687 wms.forEach(function (img, i) {
34688 var pos = pick(i < half);
34689 var size = Math.floor(Math.random() * 100 + 120);
34690 var rot = (Math.random() * 360).toFixed(1);
34691 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
34692 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;
34693 });
34694 })();
34695 (function spawnCodeParticles() {
34696 var container = document.getElementById('code-particles');
34697 if (!container) return;
34698 var snippets = ['NUM sloc','fn analyze()','code_lines','0 mixed','blanks: NUM','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','NUM physical','files: NUM','NUM comments','cargo build','Ok(run)','Vec<GEN>','match lang','fn main()','.rs .go .py','sloc_core','render_html','NUM code','HashMap<GEN, GN2>','Option<GEN>','&str','author AUT','last_change','ratio 0.CPX','complexity CPX','tokens: NUM','?; // try','let owner','commits: NUM','#[tokio::test]','assert_eq!','PCT% cover','blame AUT','churn PCT%','HEAD~CPX','dedup PCT%','cyclomatic CPX','impl Iterator','.await','+NUM -NM2','ownership','Span<GEN>','pub struct','trait Lang','unsafe','mixed: NUM','anyhow::Result','fn detect()','PathBuf','+NUM loc','coverage PCT%','physical: NUM','ULOC NUM','dup PCT%','let mut run']; var __GT=['Author','String','Token','FileRecord','Lang','u32','usize','PathBuf','Commit','Line','Span','Metric','Record','u64','char','Utf8','Ratio','Blame']; var __AN=['nima','core','web','cli','git','lang','ci','bot','sloc'];
34699 var count = 44;
34700 for (var i = 0; i < count; i++) {
34701 (function(idx) {
34702 var el = document.createElement('span');
34703 el.className = 'code-particle';
34704 el.textContent = snippets[idx % snippets.length].split('NUM').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('NM2').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('GEN').join(__GT[Math.floor(Math.random()*__GT.length)]).split('GN2').join(__GT[Math.floor(Math.random()*__GT.length)]).split('AUT').join(__AN[Math.floor(Math.random()*__AN.length)]).split('CPX').join(''+Math.floor(Math.random()*40+1)).split('PCT').join(''+Math.floor(Math.random()*44+1));
34705 var left = Math.random() * 94 + 2;
34706 var top = Math.random() * 88 + 6;
34707 var dur = (Math.random() * 10 + 9).toFixed(1);
34708 var delay = (Math.random() * 18).toFixed(1);
34709 var rot = (Math.random() * 26 - 13).toFixed(1);
34710 var op = (Math.random() * 0.108 + 0.072).toFixed(3);
34711 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';
34712 container.appendChild(el);
34713 })(i);
34714 }
34715 })();
34716})();
34717</script>
34718</body>
34719</html>
34720"##,
34721 ext = "html"
34722)]
34723pub(crate) struct LoginTemplate {
34724 pub(crate) csp_nonce: String,
34725 pub(crate) has_error: bool,
34726 pub(crate) next_url: String,
34727 pub(crate) lockout_threshold: u32,
34728}
34729
34730#[derive(Template)]
34733#[template(
34734 source = r##"
34735<!doctype html>
34736<html lang="en">
34737<head>
34738 <meta charset="utf-8">
34739 <meta name="viewport" content="width=device-width, initial-scale=1">
34740 <title>OxideSLOC — REST API Reference</title>
34741 <link rel="icon" type="image/png" href="/images/logo/small-logo.png">
34742 <link rel="stylesheet" href="/static/app.css">
34743 <script src="/static/app.js"></script>
34744 <style nonce="{{ csp_nonce }}">
34745 :root {
34746 --radius:14px; --bg:#f5efe8; --surface:rgba(255,255,255,0.86); --surface-2:#fbf7f2;
34747 --line:#e6d0bf; --line-strong:#d8bfad; --text:#43342d; --muted:#7b675b; --muted-2:#a08878;
34748 --nav:#283790; --nav-2:#013e6b; --accent:#6f9bff; --accent-2:#2563eb;
34749 --oxide:#d37a4c; --oxide-2:#b85d33; --shadow:0 18px 42px rgba(77,44,20,0.12);
34750 --success:#16a34a;
34751 }
34752 body.dark-theme {
34753 --bg:#1b1511; --surface:#261c17; --surface-2:#2d221d; --line:#524238; --line-strong:#6b5548;
34754 --text:#f5ece6; --muted:#c7b7aa; --muted-2:#9c877a; --shadow:0 18px 42px rgba(0,0,0,0.36);
34755 }
34756 *{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;}
34757 .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);}
34758 .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;}
34759 .brand{display:flex;align-items:center;gap:14px;text-decoration:none;}
34760 .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));}
34761 .brand-copy{display:flex;flex-direction:column;justify-content:center;}
34762 .brand-title{margin:0;color:#fff;font-size:17px;font-weight:800;line-height:1.1;}
34763 .brand-subtitle{color:rgba(255,255,255,0.85);font-size:12px;margin-top:2px;white-space:nowrap;}
34764 .nav-right{margin-left:auto;display:flex;align-items:center;gap:10px;flex-wrap:nowrap;}
34765 @media (max-width: 1400px) { .nav-right { gap: 6px; } .nav-pill, .nav-dropdown-btn, .theme-toggle { padding: 0 10px; } }
34766 @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; } }
34767 .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;}
34768 a.nav-pill:hover{background:rgba(255,255,255,0.18);}
34769 .nav-pill.active{background:rgba(255,255,255,0.22);}
34770 .nav-dropdown{position:relative;display:inline-flex;}
34771 .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;}
34772 .nav-dropdown-btn:hover,.nav-dropdown:focus-within .nav-dropdown-btn{background:rgba(255,255,255,0.18);}
34773 .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;}
34774 .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;}
34775 .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);}
34776 .nav-dropdown-menu a:last-child{border-bottom:none;}
34777 .nav-dropdown-menu a:hover{background:rgba(255,255,255,0.14);color:#fff;}
34778 .nav-dropdown-menu a svg{width:13px;height:13px;stroke:currentColor;fill:none;stroke-width:2;flex:0 0 auto;}
34779 .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;}
34780 .theme-toggle svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.8;}
34781 .theme-toggle .icon-sun{display:none;} body.dark-theme .theme-toggle .icon-sun{display:block;} body.dark-theme .theme-toggle .icon-moon{display:none;}
34782 .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;}
34783 .settings-modal.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1);}
34784 .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);}
34785 .settings-close{background:none;border:none;cursor:pointer;padding:4px;color:var(--muted-2);display:flex;align-items:center;border-radius:6px;}
34786 .settings-close svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.5;}
34787 .settings-modal-body{padding:14px 16px 16px;}
34788 .settings-modal-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;color:var(--muted-2);margin-bottom:10px;}
34789 .scheme-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;}
34790 .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;}
34791 .scheme-swatch:hover{border-color:var(--line-strong);transform:translateY(-1px);}
34792 .scheme-swatch.active{border-color:#6f9bff;box-shadow:0 0 0 2px rgba(111,155,255,0.25);}
34793 .scheme-preview{width:28px;height:28px;border-radius:7px;flex-shrink:0;}
34794 .scheme-label{font-size:9px;font-weight:700;color:var(--muted-2);white-space:nowrap;}
34795 .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;}
34796 .tz-select:focus{border-color:var(--oxide);}
34797 .page{max-width:960px;margin:0 auto;padding:40px 24px 36px;position:relative;z-index:1;}
34798 .page-header{margin-bottom:28px;}
34799 .page-title{font-size:28px;font-weight:900;letter-spacing:-0.03em;margin:0 0 6px;}
34800 .page-subtitle{font-size:15px;color:var(--muted);line-height:1.6;margin:0;}
34801 .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;}
34802 .callout.key-set{background:rgba(22,163,74,0.10);border:1px solid rgba(22,163,74,0.30);}
34803 .callout.no-key{background:rgba(245,158,11,0.10);border:1px solid rgba(245,158,11,0.30);}
34804 .callout-icon{width:20px;height:20px;flex:0 0 auto;margin-top:1px;}
34805 .callout strong{font-weight:800;}
34806 .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;}
34807 body.dark-theme .callout code{background:rgba(255,255,255,0.10);}
34808 .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;}
34809 .base-url-label{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);flex:0 0 auto;}
34810 .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;}
34811 body.dark-theme .base-url-value{color:var(--accent);}
34812 .section{margin-bottom:36px;}
34813 .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);}
34814 .ep-card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);margin-bottom:10px;overflow:hidden;}
34815 .ep-header{display:flex;align-items:center;gap:10px;padding:13px 16px;cursor:pointer;user-select:none;flex-wrap:wrap;}
34816 .ep-header:hover{background:var(--surface-2);}
34817 .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;}
34818 .method.get{background:#dcfce7;color:#166534;}
34819 .method.post{background:#dbeafe;color:#1e40af;}
34820 .method.delete{background:#fee2e2;color:#991b1b;}
34821 body.dark-theme .method.get{background:#14532d;color:#86efac;}
34822 body.dark-theme .method.post{background:#1e3a5f;color:#93c5fd;}
34823 body.dark-theme .method.delete{background:#450a0a;color:#fca5a5;}
34824 .ep-path{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:700;flex:1;min-width:0;}
34825 .ep-path .param{color:var(--oxide-2);}
34826 body.dark-theme .ep-path .param{color:var(--oxide);}
34827 .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;}
34828 .auth-badge.protected{background:rgba(239,68,68,0.10);color:#b91c1c;border:1px solid rgba(239,68,68,0.25);}
34829 .auth-badge.public{background:rgba(22,163,74,0.10);color:#166534;border:1px solid rgba(22,163,74,0.25);}
34830 .auth-badge.hmac{background:rgba(245,158,11,0.10);color:#b45309;border:1px solid rgba(245,158,11,0.25);}
34831 body.dark-theme .auth-badge.protected{background:rgba(239,68,68,0.18);color:#fca5a5;border-color:rgba(239,68,68,0.35);}
34832 body.dark-theme .auth-badge.public{background:rgba(22,163,74,0.18);color:#86efac;border-color:rgba(22,163,74,0.35);}
34833 body.dark-theme .auth-badge.hmac{background:rgba(245,158,11,0.18);color:#fcd34d;border-color:rgba(245,158,11,0.35);}
34834 .ep-desc{font-size:13px;color:var(--muted);flex:1;min-width:120px;}
34835 .chevron{width:16px;height:16px;stroke:var(--muted-2);fill:none;stroke-width:2;transition:transform 0.2s ease;flex:0 0 auto;}
34836 .ep-card.open .chevron{transform:rotate(180deg);}
34837 .ep-body{display:none;padding:0 16px 16px;border-top:1px solid var(--line);}
34838 .ep-card.open .ep-body{display:block;}
34839 .ep-desc-full{font-size:14px;color:var(--muted);line-height:1.6;margin:14px 0 14px;}
34840 .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;}
34841 .ep-desc-full a{color:var(--accent-2);text-decoration:none;}
34842 body.dark-theme .ep-desc-full code{background:rgba(255,255,255,0.09);}
34843 .params-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
34844 table.params{width:100%;border-collapse:collapse;margin-bottom:14px;font-size:13px;}
34845 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);}
34846 table.params td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;}
34847 table.params tr:last-child td{border-bottom:none;}
34848 .pt-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-weight:700;}
34849 .pt-type{color:var(--muted-2);font-size:12px;}
34850 .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;}
34851 .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;}
34852 body.dark-theme .pt-req{background:rgba(239,68,68,0.20);color:#fca5a5;}
34853 body.dark-theme .pt-opt{background:rgba(255,255,255,0.08);color:var(--muted);}
34854 details.schema{margin-bottom:14px;}
34855 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;}
34856 details.schema summary:hover{color:var(--text);}
34857 .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;}
34858 .curl-heading{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted-2);margin:12px 0 6px;}
34859 .curl-wrap{position:relative;}
34860 .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;}
34861 .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;}
34862 .curl-copy-btn:hover{background:var(--accent-2);color:#fff;border-color:var(--accent-2);}
34863 .curl-copy-btn.copied{background:var(--success);color:#fff;border-color:var(--success);}
34864 .webhook-note{font-size:14px;color:var(--muted);margin:0 0 14px;line-height:1.6;}
34865 .webhook-note a{color:var(--accent-2);text-decoration:none;}
34866 .background-watermarks{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
34867 .background-watermarks img{position:absolute;opacity:0.16;filter:blur(0.3px);user-select:none;max-width:none;}
34868 .code-particles{position:fixed;inset:0;pointer-events:none;z-index:0;overflow:hidden;}
34869 .code-particle{position:absolute;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px;font-weight:600;color:var(--oxide);opacity:0;white-space:nowrap;user-select:none;animation:floatCode linear infinite;}
34870 @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));}}
34871 .site-footer{text-align:center;padding:12px 24px;font-size:13px;color:var(--muted);position:relative;z-index:1;}
34872 .site-footer a{color:var(--muted);}
34873 </style>
34874</head>
34875<body>
34876 <div class="background-watermarks" aria-hidden="true">
34877 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
34878 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
34879 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
34880 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
34881 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
34882 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
34883 <img src="/images/logo/logo-text.png" alt="" /><img src="/images/logo/logo-text.png" alt="" />
34884 </div>
34885 <div class="code-particles" id="code-particles" aria-hidden="true"></div>
34886 <div class="top-nav">
34887 <div class="top-nav-inner">
34888 <a class="brand" href="/">
34889 <img class="brand-logo" src="/images/logo/small-logo.png" alt="OxideSLOC logo">
34890 <div class="brand-copy"><div class="brand-title">OxideSLOC</div><div class="brand-subtitle">REST API Reference</div></div>
34891 </a>
34892 <div class="nav-right">
34893 <a class="nav-pill" href="/">Home</a>
34894 <div class="nav-dropdown">
34895 <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>
34896 <div class="nav-dropdown-menu">
34897 <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>
34898 </div>
34899 </div>
34900 <a class="nav-pill" href="/compare-scans">Compare Scans</a>
34901 <a class="nav-pill" href="/test-metrics">Test Metrics</a>
34902 <div class="nav-dropdown">
34903 <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>
34904 <div class="nav-dropdown-menu">
34905 <a href="/code-ownership"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>Code Ownership</a>
34906 <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>
34907 </div>
34908 </div>
34909 <button type="button" class="theme-toggle" id="settings-btn" aria-label="Color scheme" title="Color scheme settings">
34910 <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>
34911 </button>
34912 <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">
34913 <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>
34914 <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>
34915 </button>
34916 </div>
34917 </div>
34918 </div>
34919
34920 <div class="page">
34921 <div class="page-header">
34922 <h1 class="page-title">REST API Reference</h1>
34923 <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>
34924 </div>
34925
34926 {% if has_api_key %}
34927 <div class="callout key-set">
34928 <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>
34929 <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>
34930 </div>
34931 {% else %}
34932 <div class="callout no-key">
34933 <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>
34934 <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>
34935 </div>
34936 {% endif %}
34937
34938 <div class="base-url-bar">
34939 <span class="base-url-label">Base URL</span>
34940 <span class="base-url-value" id="base-url">http://127.0.0.1:4317</span>
34941 </div>
34942
34943 <!-- Health -->
34944 <div class="section">
34945 <h2 class="section-title">Health & Status</h2>
34946 <div class="ep-card">
34947 <div class="ep-header">
34948 <span class="method get">GET</span>
34949 <span class="ep-path">/healthz</span>
34950 <span class="auth-badge public">Public</span>
34951 <span class="ep-desc">Server liveness check</span>
34952 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
34953 </div>
34954 <div class="ep-body">
34955 <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>
34956 <p class="params-heading">Response</p>
34957 <div class="schema-block">200 OK
34958Content-Type: text/plain
34959
34960ok</div>
34961 <p class="curl-heading">Example</p>
34962 <div class="curl-wrap">
34963 <pre class="curl-block" data-curl-id="c-healthz">curl <span class="base-url-slot">http://127.0.0.1:4317</span>/healthz</pre>
34964 <button class="curl-copy-btn" data-target="c-healthz">Copy</button>
34965 </div>
34966 </div>
34967 </div>
34968 </div>
34969
34970 <!-- Badges -->
34971 <div class="section">
34972 <h2 class="section-title">Badges</h2>
34973 <div class="ep-card">
34974 <div class="ep-header">
34975 <span class="method get">GET</span>
34976 <span class="ep-path">/badge/<span class="param">{metric}</span></span>
34977 <span class="auth-badge public">Public</span>
34978 <span class="ep-desc">SVG badge for README / dashboard embedding</span>
34979 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
34980 </div>
34981 <div class="ep-body">
34982 <p class="ep-desc-full">Returns a shields-style SVG badge showing the requested metric from the most recent scan.</p>
34983 <p class="params-heading">Path Parameters</p>
34984 <table class="params">
34985 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
34986 <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>
34987 </table>
34988 <p class="curl-heading">Example</p>
34989 <div class="curl-wrap">
34990 <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>
34991 <button class="curl-copy-btn" data-target="c-badge">Copy</button>
34992 </div>
34993 </div>
34994 </div>
34995 </div>
34996
34997 <!-- Metrics -->
34998 <div class="section">
34999 <h2 class="section-title">Metrics</h2>
35000
35001 <div class="ep-card">
35002 <div class="ep-header">
35003 <span class="method get">GET</span>
35004 <span class="ep-path">/api/metrics/latest</span>
35005 <span class="auth-badge protected">Protected</span>
35006 <span class="ep-desc">Latest scan metrics (JSON)</span>
35007 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35008 </div>
35009 <div class="ep-body">
35010 <p class="ep-desc-full">Returns detailed metrics for the most recent completed scan, including a summary and per-language breakdown.</p>
35011 <details class="schema"><summary>Response schema</summary>
35012<div class="schema-block">{
35013 "run_id": string, // UUID
35014 "timestamp": string, // ISO-8601 UTC
35015 "project": string, // scanned root path
35016 "summary": {
35017 "files_analyzed": number,
35018 "files_skipped": number,
35019 "code_lines": number,
35020 "comment_lines": number,
35021 "blank_lines": number,
35022 "total_physical_lines": number,
35023 "functions": number,
35024 "classes": number,
35025 "variables": number,
35026 "imports": number
35027 },
35028 "languages": [
35029 { "name": string, "files": number, "code_lines": number,
35030 "comment_lines": number, "blank_lines": number,
35031 "functions": number, "classes": number,
35032 "variables": number, "imports": number }
35033 ]
35034}</div></details>
35035 <p class="curl-heading">Example</p>
35036 <div class="curl-wrap">
35037 <pre class="curl-block" data-curl-id="c-metrics-latest">curl -H "Authorization: Bearer $SLOC_API_KEY" \
35038 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/latest</pre>
35039 <button class="curl-copy-btn" data-target="c-metrics-latest">Copy</button>
35040 </div>
35041 </div>
35042 </div>
35043
35044 <div class="ep-card">
35045 <div class="ep-header">
35046 <span class="method get">GET</span>
35047 <span class="ep-path">/api/metrics/<span class="param">{run_id}</span></span>
35048 <span class="auth-badge protected">Protected</span>
35049 <span class="ep-desc">Metrics for a specific run</span>
35050 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35051 </div>
35052 <div class="ep-body">
35053 <p class="ep-desc-full">Returns the same shape as <code>/api/metrics/latest</code> but for a specific run identified by UUID.</p>
35054 <p class="params-heading">Path Parameters</p>
35055 <table class="params">
35056 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
35057 <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>
35058 </table>
35059 <p class="curl-heading">Example</p>
35060 <div class="curl-wrap">
35061 <pre class="curl-block" data-curl-id="c-metrics-run">curl -H "Authorization: Bearer $SLOC_API_KEY" \
35062 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/<run_id></pre>
35063 <button class="curl-copy-btn" data-target="c-metrics-run">Copy</button>
35064 </div>
35065 </div>
35066 </div>
35067
35068 <div class="ep-card">
35069 <div class="ep-header">
35070 <span class="method get">GET</span>
35071 <span class="ep-path">/api/metrics/history</span>
35072 <span class="auth-badge protected">Protected</span>
35073 <span class="ep-desc">Paginated scan history</span>
35074 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35075 </div>
35076 <div class="ep-body">
35077 <p class="ep-desc-full">Returns an array of scan history entries, newest-first. Optionally filtered by root path.</p>
35078 <p class="params-heading">Query Parameters</p>
35079 <table class="params">
35080 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
35081 <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>
35082 <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>
35083 </table>
35084 <details class="schema"><summary>Response schema</summary>
35085<div class="schema-block">[{
35086 "run_id": string,
35087 "timestamp": string, // ISO-8601 UTC
35088 "commit": string | null,
35089 "branch": string | null,
35090 "tags": string[],
35091 "code_lines": number,
35092 "comment_lines": number,
35093 "blank_lines": number,
35094 "physical_lines": number,
35095 "files_analyzed": number,
35096 "project_label": string,
35097 "html_url": string | null
35098}]</div></details>
35099 <p class="curl-heading">Example</p>
35100 <div class="curl-wrap">
35101 <pre class="curl-block" data-curl-id="c-metrics-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
35102 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/history?limit=10"</pre>
35103 <button class="curl-copy-btn" data-target="c-metrics-history">Copy</button>
35104 </div>
35105 </div>
35106 </div>
35107
35108 <div class="ep-card">
35109 <div class="ep-header">
35110 <span class="method get">GET</span>
35111 <span class="ep-path">/api/project-history</span>
35112 <span class="auth-badge protected">Protected</span>
35113 <span class="ep-desc">Project-level scan summary</span>
35114 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35115 </div>
35116 <div class="ep-body">
35117 <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>
35118 <p class="params-heading">Query Parameters</p>
35119 <table class="params">
35120 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
35121 <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>
35122 </table>
35123 <details class="schema"><summary>Response schema</summary>
35124<div class="schema-block">{
35125 "scan_count": number,
35126 "last_scan_id": string | null,
35127 "last_scan_timestamp": string | null, // ISO-8601
35128 "last_scan_code_lines": number | null,
35129 "last_git_branch": string | null,
35130 "last_git_commit": string | null
35131}</div></details>
35132 <p class="curl-heading">Example</p>
35133 <div class="curl-wrap">
35134 <pre class="curl-block" data-curl-id="c-proj-history">curl -H "Authorization: Bearer $SLOC_API_KEY" \
35135 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/project-history</pre>
35136 <button class="curl-copy-btn" data-target="c-proj-history">Copy</button>
35137 </div>
35138 </div>
35139 </div>
35140
35141 <div class="ep-card">
35142 <div class="ep-header">
35143 <span class="method get">GET</span>
35144 <span class="ep-path">/api/metrics/submodules</span>
35145 <span class="auth-badge protected">Protected</span>
35146 <span class="ep-desc">List known git submodules across scans</span>
35147 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35148 </div>
35149 <div class="ep-body">
35150 <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>
35151 <p class="params-heading">Query Parameters</p>
35152 <table class="params">
35153 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
35154 <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>
35155 </table>
35156 <details class="schema"><summary>Response schema</summary>
35157<div class="schema-block">[{
35158 "name": string, // submodule name
35159 "relative_path": string // path relative to the project root
35160}]</div></details>
35161 <p class="curl-heading">Example</p>
35162 <div class="curl-wrap">
35163 <pre class="curl-block" data-curl-id="c-metrics-submodules">curl -H "Authorization: Bearer $SLOC_API_KEY" \
35164 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/metrics/submodules?root=/path/to/repo"</pre>
35165 <button class="curl-copy-btn" data-target="c-metrics-submodules">Copy</button>
35166 </div>
35167 </div>
35168 </div>
35169 </div>
35170
35171 <!-- Async Run Status -->
35172 <div class="section">
35173 <h2 class="section-title">Async Run Status</h2>
35174
35175 <div class="ep-card">
35176 <div class="ep-header">
35177 <span class="method get">GET</span>
35178 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/status</span>
35179 <span class="auth-badge protected">Protected</span>
35180 <span class="ep-desc">Poll scan completion</span>
35181 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35182 </div>
35183 <div class="ep-body">
35184 <p class="ep-desc-full">Poll after submitting a scan. The <code>state</code> field discriminates the response shape.</p>
35185 <details class="schema"><summary>Response schema</summary>
35186<div class="schema-block">// Running
35187{ "state": "running", "elapsed_secs": number }
35188
35189// Complete
35190{ "state": "complete", "run_id": string }
35191
35192// Failed
35193{ "state": "failed", "message": string }</div></details>
35194 <p class="curl-heading">Example</p>
35195 <div class="curl-wrap">
35196 <pre class="curl-block" data-curl-id="c-run-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
35197 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/status</pre>
35198 <button class="curl-copy-btn" data-target="c-run-status">Copy</button>
35199 </div>
35200 </div>
35201 </div>
35202
35203 <div class="ep-card">
35204 <div class="ep-header">
35205 <span class="method get">GET</span>
35206 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/pdf-status</span>
35207 <span class="auth-badge protected">Protected</span>
35208 <span class="ep-desc">Poll PDF generation readiness</span>
35209 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35210 </div>
35211 <div class="ep-body">
35212 <p class="ep-desc-full">Returns whether the PDF artifact for a completed run is ready for download.</p>
35213 <details class="schema"><summary>Response schema</summary>
35214<div class="schema-block">{ "ready": boolean, "url": string | null }</div></details>
35215 <p class="curl-heading">Example</p>
35216 <div class="curl-wrap">
35217 <pre class="curl-block" data-curl-id="c-pdf-status">curl -H "Authorization: Bearer $SLOC_API_KEY" \
35218 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/pdf-status</pre>
35219 <button class="curl-copy-btn" data-target="c-pdf-status">Copy</button>
35220 </div>
35221 </div>
35222 </div>
35223
35224 <div class="ep-card">
35225 <div class="ep-header">
35226 <span class="method post">POST</span>
35227 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/cancel</span>
35228 <span class="auth-badge protected">Protected</span>
35229 <span class="ep-desc">Cancel a running scan</span>
35230 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35231 </div>
35232 <div class="ep-body">
35233 <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>
35234 <p class="curl-heading">Example</p>
35235 <div class="curl-wrap">
35236 <pre class="curl-block" data-curl-id="c-run-cancel">curl -X POST \
35237 -H "Authorization: Bearer $SLOC_API_KEY" \
35238 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/cancel</pre>
35239 <button class="curl-copy-btn" data-target="c-run-cancel">Copy</button>
35240 </div>
35241 </div>
35242 </div>
35243 </div>
35244
35245 <!-- Run Management -->
35246 <div class="section">
35247 <h2 class="section-title">Run Management</h2>
35248
35249 <div class="ep-card">
35250 <div class="ep-header">
35251 <span class="method get">GET</span>
35252 <span class="ep-path">/api/runs/<span class="param">{run_id}</span>/bundle</span>
35253 <span class="auth-badge protected">Protected</span>
35254 <span class="ep-desc">Download all artifacts for a run as a ZIP archive</span>
35255 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35256 </div>
35257 <div class="ep-body">
35258 <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>
35259 <p class="params-heading">Path Parameters</p>
35260 <table class="params">
35261 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
35262 <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>
35263 </table>
35264 <details class="schema"><summary>Response</summary>
35265<div class="schema-block">200 OK — Content-Type: application/zip
35266Content-Disposition: attachment; filename="sloc-run-<run_id>.zip"
35267
35268404 Not Found — { "error": string } (run not found or no artifacts)</div></details>
35269 <p class="curl-heading">Example</p>
35270 <div class="curl-wrap">
35271 <pre class="curl-block" data-curl-id="c-run-bundle">curl -H "Authorization: Bearer $SLOC_API_KEY" \
35272 -o run.zip \
35273 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id>/bundle</pre>
35274 <button class="curl-copy-btn" data-target="c-run-bundle">Copy</button>
35275 </div>
35276 </div>
35277 </div>
35278
35279 <div class="ep-card">
35280 <div class="ep-header">
35281 <span class="method delete">DELETE</span>
35282 <span class="ep-path">/api/runs/<span class="param">{run_id}</span></span>
35283 <span class="auth-badge protected">Protected</span>
35284 <span class="ep-desc">Permanently delete a run and all its artifacts</span>
35285 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35286 </div>
35287 <div class="ep-body">
35288 <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>
35289 <p class="params-heading">Path Parameters</p>
35290 <table class="params">
35291 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
35292 <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>
35293 </table>
35294 <details class="schema"><summary>Response</summary>
35295<div class="schema-block">204 No Content — run successfully deleted
35296
35297500 Internal Server Error — { "error": string } (filesystem deletion failed)</div></details>
35298 <p class="curl-heading">Example</p>
35299 <div class="curl-wrap">
35300 <pre class="curl-block" data-curl-id="c-run-delete">curl -X DELETE \
35301 -H "Authorization: Bearer $SLOC_API_KEY" \
35302 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/<run_id></pre>
35303 <button class="curl-copy-btn" data-target="c-run-delete">Copy</button>
35304 </div>
35305 </div>
35306 </div>
35307
35308 <div class="ep-card">
35309 <div class="ep-header">
35310 <span class="method post">POST</span>
35311 <span class="ep-path">/api/runs/cleanup</span>
35312 <span class="auth-badge protected">Protected</span>
35313 <span class="ep-desc">Bulk delete runs older than N days</span>
35314 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35315 </div>
35316 <div class="ep-body">
35317 <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>
35318 <p class="params-heading">Request Body (application/json)</p>
35319 <table class="params">
35320 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
35321 <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>
35322 </table>
35323 <details class="schema"><summary>Response schema</summary>
35324<div class="schema-block">{ "deleted": number } // count of runs removed</div></details>
35325 <p class="curl-heading">Example — delete runs older than 60 days</p>
35326 <div class="curl-wrap">
35327 <pre class="curl-block" data-curl-id="c-runs-cleanup">curl -X POST \
35328 -H "Authorization: Bearer $SLOC_API_KEY" \
35329 -H "Content-Type: application/json" \
35330 -d '{"older_than_days":60}' \
35331 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/runs/cleanup</pre>
35332 <button class="curl-copy-btn" data-target="c-runs-cleanup">Copy</button>
35333 </div>
35334 </div>
35335 </div>
35336 </div>
35337
35338 <!-- Retention Policy -->
35339 <div class="section">
35340 <h2 class="section-title">Retention Policy</h2>
35341
35342 <div class="ep-card">
35343 <div class="ep-header">
35344 <span class="method get">GET</span>
35345 <span class="ep-path">/api/cleanup-policy</span>
35346 <span class="auth-badge protected">Protected</span>
35347 <span class="ep-desc">Get the current retention policy and last-run metadata</span>
35348 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35349 </div>
35350 <div class="ep-body">
35351 <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>
35352 <details class="schema"><summary>Response schema</summary>
35353<div class="schema-block">{
35354 "policy": {
35355 "enabled": boolean,
35356 "max_age_days": number | null, // delete runs older than N days
35357 "max_run_count": number | null, // keep only the N most recent runs
35358 "interval_hours": number // hours between background passes
35359 } | null,
35360 "last_run_at": string | null, // ISO-8601 UTC timestamp
35361 "last_run_deleted": number | null // runs deleted in last pass
35362}</div></details>
35363 <p class="curl-heading">Example</p>
35364 <div class="curl-wrap">
35365 <pre class="curl-block" data-curl-id="c-policy-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
35366 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
35367 <button class="curl-copy-btn" data-target="c-policy-get">Copy</button>
35368 </div>
35369 </div>
35370 </div>
35371
35372 <div class="ep-card">
35373 <div class="ep-header">
35374 <span class="method post">POST</span>
35375 <span class="ep-path">/api/cleanup-policy</span>
35376 <span class="auth-badge protected">Protected</span>
35377 <span class="ep-desc">Save or update the retention policy</span>
35378 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35379 </div>
35380 <div class="ep-body">
35381 <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>
35382 <p class="params-heading">Request Body (application/json)</p>
35383 <table class="params">
35384 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
35385 <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>
35386 <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>
35387 <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>
35388 <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>
35389 </table>
35390 <details class="schema"><summary>Response</summary>
35391<div class="schema-block">204 No Content — policy saved and task (re)started
35392
35393500 Internal Server Error — { "error": string }</div></details>
35394 <p class="curl-heading">Example — keep 30 days, max 100 runs, check daily</p>
35395 <div class="curl-wrap">
35396 <pre class="curl-block" data-curl-id="c-policy-post">curl -X POST \
35397 -H "Authorization: Bearer $SLOC_API_KEY" \
35398 -H "Content-Type: application/json" \
35399 -d '{"enabled":true,"max_age_days":30,"max_run_count":100,"interval_hours":24}' \
35400 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
35401 <button class="curl-copy-btn" data-target="c-policy-post">Copy</button>
35402 </div>
35403 </div>
35404 </div>
35405
35406 <div class="ep-card">
35407 <div class="ep-header">
35408 <span class="method post">POST</span>
35409 <span class="ep-path">/api/cleanup-policy/run-now</span>
35410 <span class="auth-badge protected">Protected</span>
35411 <span class="ep-desc">Trigger an immediate cleanup pass</span>
35412 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35413 </div>
35414 <div class="ep-body">
35415 <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>
35416 <details class="schema"><summary>Response schema</summary>
35417<div class="schema-block">{ "deleted": number } // count of runs removed in this pass</div></details>
35418 <p class="curl-heading">Example</p>
35419 <div class="curl-wrap">
35420 <pre class="curl-block" data-curl-id="c-policy-run-now">curl -X POST \
35421 -H "Authorization: Bearer $SLOC_API_KEY" \
35422 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy/run-now</pre>
35423 <button class="curl-copy-btn" data-target="c-policy-run-now">Copy</button>
35424 </div>
35425 </div>
35426 </div>
35427
35428 <div class="ep-card">
35429 <div class="ep-header">
35430 <span class="method delete">DELETE</span>
35431 <span class="ep-path">/api/cleanup-policy</span>
35432 <span class="auth-badge protected">Protected</span>
35433 <span class="ep-desc">Remove the retention policy and stop the background task</span>
35434 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35435 </div>
35436 <div class="ep-body">
35437 <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>
35438 <details class="schema"><summary>Response</summary>
35439<div class="schema-block">204 No Content — policy removed and task stopped</div></details>
35440 <p class="curl-heading">Example</p>
35441 <div class="curl-wrap">
35442 <pre class="curl-block" data-curl-id="c-policy-delete">curl -X DELETE \
35443 -H "Authorization: Bearer $SLOC_API_KEY" \
35444 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/cleanup-policy</pre>
35445 <button class="curl-copy-btn" data-target="c-policy-delete">Copy</button>
35446 </div>
35447 </div>
35448 </div>
35449 </div>
35450
35451 <!-- Scan Profiles -->
35452 <div class="section">
35453 <h2 class="section-title">Scan Profiles</h2>
35454
35455 <div class="ep-card">
35456 <div class="ep-header">
35457 <span class="method get">GET</span>
35458 <span class="ep-path">/api/scan-profiles</span>
35459 <span class="auth-badge protected">Protected</span>
35460 <span class="ep-desc">List saved scan profiles</span>
35461 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35462 </div>
35463 <div class="ep-body">
35464 <p class="ep-desc-full">Returns all saved scan profiles. Profiles store scan parameters that can be pre-loaded into the scan form.</p>
35465 <details class="schema"><summary>Response schema</summary>
35466<div class="schema-block">{
35467 "profiles": [{
35468 "id": string, // UUID
35469 "name": string,
35470 "created_at": string, // ISO-8601
35471 "params": object
35472 }]
35473}</div></details>
35474 <p class="curl-heading">Example</p>
35475 <div class="curl-wrap">
35476 <pre class="curl-block" data-curl-id="c-profiles-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
35477 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
35478 <button class="curl-copy-btn" data-target="c-profiles-list">Copy</button>
35479 </div>
35480 </div>
35481 </div>
35482
35483 <div class="ep-card">
35484 <div class="ep-header">
35485 <span class="method post">POST</span>
35486 <span class="ep-path">/api/scan-profiles</span>
35487 <span class="auth-badge protected">Protected</span>
35488 <span class="ep-desc">Save a scan profile</span>
35489 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35490 </div>
35491 <div class="ep-body">
35492 <p class="ep-desc-full">Creates a named scan profile. The <code>params</code> field accepts any JSON object containing scan settings.</p>
35493 <p class="params-heading">Request Body (application/json)</p>
35494 <table class="params">
35495 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
35496 <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>
35497 <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>
35498 </table>
35499 <details class="schema"><summary>Response schema</summary>
35500<div class="schema-block">{ "ok": true }</div></details>
35501 <p class="curl-heading">Example</p>
35502 <div class="curl-wrap">
35503 <pre class="curl-block" data-curl-id="c-profiles-save">curl -X POST \
35504 -H "Authorization: Bearer $SLOC_API_KEY" \
35505 -H "Content-Type: application/json" \
35506 -d '{"name":"My Profile","params":{"path":"/my/repo"}}' \
35507 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles</pre>
35508 <button class="curl-copy-btn" data-target="c-profiles-save">Copy</button>
35509 </div>
35510 </div>
35511 </div>
35512
35513 <div class="ep-card">
35514 <div class="ep-header">
35515 <span class="method delete">DELETE</span>
35516 <span class="ep-path">/api/scan-profiles/<span class="param">{id}</span></span>
35517 <span class="auth-badge protected">Protected</span>
35518 <span class="ep-desc">Delete a scan profile</span>
35519 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35520 </div>
35521 <div class="ep-body">
35522 <p class="ep-desc-full">Permanently deletes a scan profile by its UUID.</p>
35523 <p class="params-heading">Path Parameters</p>
35524 <table class="params">
35525 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
35526 <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>
35527 </table>
35528 <details class="schema"><summary>Response schema</summary>
35529<div class="schema-block">{ "ok": true }</div></details>
35530 <p class="curl-heading">Example</p>
35531 <div class="curl-wrap">
35532 <pre class="curl-block" data-curl-id="c-profiles-del">curl -X DELETE \
35533 -H "Authorization: Bearer $SLOC_API_KEY" \
35534 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/scan-profiles/<id></pre>
35535 <button class="curl-copy-btn" data-target="c-profiles-del">Copy</button>
35536 </div>
35537 </div>
35538 </div>
35539 </div>
35540
35541 <!-- Scheduled Scans -->
35542 <div class="section">
35543 <h2 class="section-title">Scheduled Scans</h2>
35544
35545 <div class="ep-card">
35546 <div class="ep-header">
35547 <span class="method get">GET</span>
35548 <span class="ep-path">/api/schedules</span>
35549 <span class="auth-badge protected">Protected</span>
35550 <span class="ep-desc">List configured schedules</span>
35551 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35552 </div>
35553 <div class="ep-body">
35554 <p class="ep-desc-full">Returns all configured scheduled scans. See <a href="/integrations">Integrations</a> for the full schedule object schema.</p>
35555 <p class="curl-heading">Example</p>
35556 <div class="curl-wrap">
35557 <pre class="curl-block" data-curl-id="c-sched-list">curl -H "Authorization: Bearer $SLOC_API_KEY" \
35558 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
35559 <button class="curl-copy-btn" data-target="c-sched-list">Copy</button>
35560 </div>
35561 </div>
35562 </div>
35563
35564 <div class="ep-card">
35565 <div class="ep-header">
35566 <span class="method post">POST</span>
35567 <span class="ep-path">/api/schedules</span>
35568 <span class="auth-badge protected">Protected</span>
35569 <span class="ep-desc">Create a schedule</span>
35570 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35571 </div>
35572 <div class="ep-body">
35573 <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>
35574 <p class="curl-heading">Example</p>
35575 <div class="curl-wrap">
35576 <pre class="curl-block" data-curl-id="c-sched-create">curl -X POST \
35577 -H "Authorization: Bearer $SLOC_API_KEY" \
35578 -H "Content-Type: application/json" \
35579 -d '{"label":"nightly","repo_url":"https://github.com/org/repo","cron":"0 2 * * *"}' \
35580 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
35581 <button class="curl-copy-btn" data-target="c-sched-create">Copy</button>
35582 </div>
35583 </div>
35584 </div>
35585
35586 <div class="ep-card">
35587 <div class="ep-header">
35588 <span class="method delete">DELETE</span>
35589 <span class="ep-path">/api/schedules</span>
35590 <span class="auth-badge protected">Protected</span>
35591 <span class="ep-desc">Delete a schedule</span>
35592 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35593 </div>
35594 <div class="ep-body">
35595 <p class="ep-desc-full">Removes a scheduled scan by its ID.</p>
35596 <p class="curl-heading">Example</p>
35597 <div class="curl-wrap">
35598 <pre class="curl-block" data-curl-id="c-sched-del">curl -X DELETE \
35599 -H "Authorization: Bearer $SLOC_API_KEY" \
35600 -H "Content-Type: application/json" \
35601 -d '{"id":"<schedule_id>"}' \
35602 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/schedules</pre>
35603 <button class="curl-copy-btn" data-target="c-sched-del">Copy</button>
35604 </div>
35605 </div>
35606 </div>
35607 </div>
35608
35609 <!-- Git Browser -->
35610 <div class="section">
35611 <h2 class="section-title">Git Browser</h2>
35612
35613 <div class="ep-card">
35614 <div class="ep-header">
35615 <span class="method get">GET</span>
35616 <span class="ep-path">/api/git/refs</span>
35617 <span class="auth-badge protected">Protected</span>
35618 <span class="ep-desc">List git refs for a repository</span>
35619 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35620 </div>
35621 <div class="ep-body">
35622 <p class="ep-desc-full">Returns all branches and tags for a local git repository.</p>
35623 <p class="params-heading">Query Parameters</p>
35624 <table class="params">
35625 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
35626 <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>
35627 </table>
35628 <p class="curl-heading">Example</p>
35629 <div class="curl-wrap">
35630 <pre class="curl-block" data-curl-id="c-git-refs">curl -H "Authorization: Bearer $SLOC_API_KEY" \
35631 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/refs?repo=/path/to/repo"</pre>
35632 <button class="curl-copy-btn" data-target="c-git-refs">Copy</button>
35633 </div>
35634 </div>
35635 </div>
35636
35637 <div class="ep-card">
35638 <div class="ep-header">
35639 <span class="method get">GET</span>
35640 <span class="ep-path">/api/git/scan-ref</span>
35641 <span class="auth-badge protected">Protected</span>
35642 <span class="ep-desc">SLOC-scan a specific git ref</span>
35643 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35644 </div>
35645 <div class="ep-body">
35646 <p class="ep-desc-full">Checks out a specific commit, branch, or tag and runs an SLOC analysis against it.</p>
35647 <p class="params-heading">Query Parameters</p>
35648 <table class="params">
35649 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
35650 <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>
35651 <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>
35652 </table>
35653 <p class="curl-heading">Example</p>
35654 <div class="curl-wrap">
35655 <pre class="curl-block" data-curl-id="c-git-scan">curl -H "Authorization: Bearer $SLOC_API_KEY" \
35656 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/git/scan-ref?repo=/path/to/repo&ref_name=main"</pre>
35657 <button class="curl-copy-btn" data-target="c-git-scan">Copy</button>
35658 </div>
35659 </div>
35660 </div>
35661
35662 <div class="ep-card">
35663 <div class="ep-header">
35664 <span class="method get">GET</span>
35665 <span class="ep-path">/api/git/compare-refs</span>
35666 <span class="auth-badge protected">Protected</span>
35667 <span class="ep-desc">Compare SLOC across two git refs</span>
35668 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35669 </div>
35670 <div class="ep-body">
35671 <p class="ep-desc-full">Runs SLOC analysis on two refs and returns the delta between them.</p>
35672 <p class="params-heading">Query Parameters</p>
35673 <table class="params">
35674 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
35675 <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>
35676 <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>
35677 <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>
35678 </table>
35679 <p class="curl-heading">Example</p>
35680 <div class="curl-wrap">
35681 <pre class="curl-block" data-curl-id="c-git-compare">curl -H "Authorization: Bearer $SLOC_API_KEY" \
35682 "<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>
35683 <button class="curl-copy-btn" data-target="c-git-compare">Copy</button>
35684 </div>
35685 </div>
35686 </div>
35687 </div>
35688
35689 <!-- Webhooks -->
35690 <div class="section">
35691 <h2 class="section-title">Webhooks</h2>
35692 <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>
35693
35694 <div class="ep-card">
35695 <div class="ep-header">
35696 <span class="method post">POST</span>
35697 <span class="ep-path">/webhooks/github</span>
35698 <span class="auth-badge hmac">HMAC</span>
35699 <span class="ep-desc">GitHub push event receiver</span>
35700 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35701 </div>
35702 <div class="ep-body">
35703 <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>
35704 <p class="params-heading">Required Headers</p>
35705 <table class="params">
35706 <tr><th>Header</th><th>Value</th></tr>
35707 <tr><td class="pt-name">X-Hub-Signature-256</td><td>HMAC-SHA256 of the raw body using the per-schedule secret</td></tr>
35708 <tr><td class="pt-name">X-GitHub-Event</td><td><code>push</code></td></tr>
35709 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
35710 </table>
35711 </div>
35712 </div>
35713
35714 <div class="ep-card">
35715 <div class="ep-header">
35716 <span class="method post">POST</span>
35717 <span class="ep-path">/webhooks/gitlab</span>
35718 <span class="auth-badge hmac">HMAC</span>
35719 <span class="ep-desc">GitLab push event receiver</span>
35720 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35721 </div>
35722 <div class="ep-body">
35723 <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>
35724 <p class="params-heading">Required Headers</p>
35725 <table class="params">
35726 <tr><th>Header</th><th>Value</th></tr>
35727 <tr><td class="pt-name">X-Gitlab-Token</td><td>Per-schedule webhook secret</td></tr>
35728 <tr><td class="pt-name">X-Gitlab-Event</td><td><code>Push Hook</code></td></tr>
35729 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
35730 </table>
35731 </div>
35732 </div>
35733
35734 <div class="ep-card">
35735 <div class="ep-header">
35736 <span class="method post">POST</span>
35737 <span class="ep-path">/webhooks/bitbucket</span>
35738 <span class="auth-badge hmac">HMAC</span>
35739 <span class="ep-desc">Bitbucket push event receiver</span>
35740 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35741 </div>
35742 <div class="ep-body">
35743 <p class="ep-desc-full">Receives Bitbucket push events. Authenticated via <code>X-Hub-Signature</code> HMAC-SHA256.</p>
35744 <p class="params-heading">Required Headers</p>
35745 <table class="params">
35746 <tr><th>Header</th><th>Value</th></tr>
35747 <tr><td class="pt-name">X-Hub-Signature</td><td>HMAC-SHA256 of the raw body</td></tr>
35748 <tr><td class="pt-name">Content-Type</td><td><code>application/json</code></td></tr>
35749 </table>
35750 </div>
35751 </div>
35752 </div>
35753
35754 <!-- Config -->
35755 <div class="section">
35756 <h2 class="section-title">Config Import / Export</h2>
35757
35758 <div class="ep-card">
35759 <div class="ep-header">
35760 <span class="method get">GET</span>
35761 <span class="ep-path">/export-config</span>
35762 <span class="auth-badge protected">Protected</span>
35763 <span class="ep-desc">Export server configuration as JSON</span>
35764 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35765 </div>
35766 <div class="ep-body">
35767 <p class="ep-desc-full">Returns the current server configuration as a downloadable JSON file.</p>
35768 <p class="curl-heading">Example</p>
35769 <div class="curl-wrap">
35770 <pre class="curl-block" data-curl-id="c-export">curl -H "Authorization: Bearer $SLOC_API_KEY" \
35771 -o config.json \
35772 <span class="base-url-slot">http://127.0.0.1:4317</span>/export-config</pre>
35773 <button class="curl-copy-btn" data-target="c-export">Copy</button>
35774 </div>
35775 </div>
35776 </div>
35777
35778 <div class="ep-card">
35779 <div class="ep-header">
35780 <span class="method post">POST</span>
35781 <span class="ep-path">/import-config</span>
35782 <span class="auth-badge protected">Protected</span>
35783 <span class="ep-desc">Import server configuration</span>
35784 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35785 </div>
35786 <div class="ep-body">
35787 <p class="ep-desc-full">Imports a previously exported configuration JSON, replacing the active server configuration.</p>
35788 <p class="curl-heading">Example</p>
35789 <div class="curl-wrap">
35790 <pre class="curl-block" data-curl-id="c-import">curl -X POST \
35791 -H "Authorization: Bearer $SLOC_API_KEY" \
35792 -H "Content-Type: application/json" \
35793 -d @config.json \
35794 <span class="base-url-slot">http://127.0.0.1:4317</span>/import-config</pre>
35795 <button class="curl-copy-btn" data-target="c-import">Copy</button>
35796 </div>
35797 </div>
35798 </div>
35799 </div>
35800
35801 <!-- CI Ingest -->
35802 <div class="section">
35803 <h2 class="section-title">CI Ingest</h2>
35804
35805 <div class="ep-card">
35806 <div class="ep-header">
35807 <span class="method post">POST</span>
35808 <span class="ep-path">/api/ingest</span>
35809 <span class="auth-badge protected">Protected</span>
35810 <span class="ep-desc">Push a pre-computed scan result from CI</span>
35811 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35812 </div>
35813 <div class="ep-body">
35814 <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>
35815 <p class="params-heading">Query Parameters</p>
35816 <table class="params">
35817 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
35818 <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>
35819 </table>
35820 <p class="params-heading">Request Body (application/json)</p>
35821 <p class="sx-73b3d091" >Full <code>AnalysisRun</code> JSON as produced by the CLI <code>--json-out</code> flag.</p>
35822 <details class="schema"><summary>Response schema</summary>
35823<div class="schema-block">// 201 Created
35824{
35825 "run_id": string, // UUID of the ingested run
35826 "view_url": string // relative URL to the report page
35827}</div></details>
35828 <p class="curl-heading">Example</p>
35829 <div class="curl-wrap">
35830 <pre class="curl-block" data-curl-id="c-ingest">curl -X POST \
35831 -H "Authorization: Bearer $SLOC_API_KEY" \
35832 -H "Content-Type: application/json" \
35833 -d @result.json \
35834 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/ingest?label=my-project"</pre>
35835 <button class="curl-copy-btn" data-target="c-ingest">Copy</button>
35836 </div>
35837 </div>
35838 </div>
35839 </div>
35840
35841 <!-- Artifact Download -->
35842 <div class="section">
35843 <h2 class="section-title">Artifact Download</h2>
35844
35845 <div class="ep-card">
35846 <div class="ep-header">
35847 <span class="method get">GET</span>
35848 <span class="ep-path">/runs/<span class="param">{artifact}</span>/<span class="param">{run_id}</span></span>
35849 <span class="auth-badge protected">Protected</span>
35850 <span class="ep-desc">Download or view a scan artifact</span>
35851 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35852 </div>
35853 <div class="ep-body">
35854 <p class="ep-desc-full">Serves a stored artifact for a completed run. The <code>artifact</code> segment selects which file to return.</p>
35855 <p class="params-heading">Path Parameters</p>
35856 <table class="params">
35857 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
35858 <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>
35859 <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>
35860 </table>
35861 <p class="params-heading">Query Parameters</p>
35862 <table class="params">
35863 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
35864 <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>
35865 </table>
35866 <p class="curl-heading">Example — download JSON result</p>
35867 <div class="curl-wrap">
35868 <pre class="curl-block" data-curl-id="c-artifact-json">curl -H "Authorization: Bearer $SLOC_API_KEY" \
35869 -o result.json \
35870 "<span class="base-url-slot">http://127.0.0.1:4317</span>/runs/json/<run_id>?download=1"</pre>
35871 <button class="curl-copy-btn" data-target="c-artifact-json">Copy</button>
35872 </div>
35873 </div>
35874 </div>
35875 </div>
35876
35877 <!-- Embed Widget -->
35878 <div class="section">
35879 <h2 class="section-title">Embed Widget</h2>
35880
35881 <div class="ep-card">
35882 <div class="ep-header">
35883 <span class="method get">GET</span>
35884 <span class="ep-path">/embed/summary</span>
35885 <span class="auth-badge protected">Protected</span>
35886 <span class="ep-desc">Embeddable scan summary widget (iframe)</span>
35887 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35888 </div>
35889 <div class="ep-body">
35890 <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>
35891 <p class="params-heading">Query Parameters</p>
35892 <table class="params">
35893 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
35894 <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>
35895 <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>
35896 </table>
35897 <p class="curl-heading">Example</p>
35898 <div class="curl-wrap">
35899 <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"
35900 width="460" height="260" style="border:none"></iframe></pre>
35901 <button class="curl-copy-btn" data-target="c-embed">Copy</button>
35902 </div>
35903 </div>
35904 </div>
35905 </div>
35906
35907 <!-- Confluence Integration -->
35908 <div class="section">
35909 <h2 class="section-title">Confluence Integration</h2>
35910
35911 <div class="ep-card">
35912 <div class="ep-header">
35913 <span class="method get">GET</span>
35914 <span class="ep-path">/api/confluence/config</span>
35915 <span class="auth-badge protected">Protected</span>
35916 <span class="ep-desc">Get current Confluence configuration</span>
35917 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35918 </div>
35919 <div class="ep-body">
35920 <p class="ep-desc-full">Returns the active Confluence integration settings. The API token / password is never returned — only whether one is set.</p>
35921 <details class="schema"><summary>Response schema</summary>
35922<div class="schema-block">{
35923 "configured": boolean,
35924 "tier": "cloud" | "server",
35925 "base_url": string,
35926 "username": string,
35927 "api_token_set": boolean,
35928 "space_key": string,
35929 "parent_page_id": string | null,
35930 "schedule_auto_post": { "<schedule_id>": boolean }
35931}</div></details>
35932 <p class="curl-heading">Example</p>
35933 <div class="curl-wrap">
35934 <pre class="curl-block" data-curl-id="c-cf-get">curl -H "Authorization: Bearer $SLOC_API_KEY" \
35935 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
35936 <button class="curl-copy-btn" data-target="c-cf-get">Copy</button>
35937 </div>
35938 </div>
35939 </div>
35940
35941 <div class="ep-card">
35942 <div class="ep-header">
35943 <span class="method post">POST</span>
35944 <span class="ep-path">/api/confluence/config</span>
35945 <span class="auth-badge protected">Protected</span>
35946 <span class="ep-desc">Save Confluence configuration</span>
35947 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35948 </div>
35949 <div class="ep-body">
35950 <p class="ep-desc-full">Persists the Confluence connection settings. Omit <code>credential</code> to keep the existing token.</p>
35951 <p class="params-heading">Request Body (application/json)</p>
35952 <table class="params">
35953 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
35954 <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>
35955 <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>
35956 <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>
35957 <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>
35958 <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>
35959 <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>
35960 <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>
35961 </table>
35962 <details class="schema"><summary>Response schema</summary>
35963<div class="schema-block">{ "ok": true }</div></details>
35964 <p class="curl-heading">Example</p>
35965 <div class="curl-wrap">
35966 <pre class="curl-block" data-curl-id="c-cf-save">curl -X POST \
35967 -H "Authorization: Bearer $SLOC_API_KEY" \
35968 -H "Content-Type: application/json" \
35969 -d '{"base_url":"https://myorg.atlassian.net","username":"me@example.com","credential":"my-token","space_key":"ENG"}' \
35970 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/config</pre>
35971 <button class="curl-copy-btn" data-target="c-cf-save">Copy</button>
35972 </div>
35973 </div>
35974 </div>
35975
35976 <div class="ep-card">
35977 <div class="ep-header">
35978 <span class="method post">POST</span>
35979 <span class="ep-path">/api/confluence/test</span>
35980 <span class="auth-badge protected">Protected</span>
35981 <span class="ep-desc">Test Confluence connection</span>
35982 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
35983 </div>
35984 <div class="ep-body">
35985 <p class="ep-desc-full">Verifies that the saved credentials can connect to and authenticate with Confluence. No request body required.</p>
35986 <details class="schema"><summary>Response schema</summary>
35987<div class="schema-block">{ "ok": boolean, "error": string | undefined }</div></details>
35988 <p class="curl-heading">Example</p>
35989 <div class="curl-wrap">
35990 <pre class="curl-block" data-curl-id="c-cf-test">curl -X POST \
35991 -H "Authorization: Bearer $SLOC_API_KEY" \
35992 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/test</pre>
35993 <button class="curl-copy-btn" data-target="c-cf-test">Copy</button>
35994 </div>
35995 </div>
35996 </div>
35997
35998 <div class="ep-card">
35999 <div class="ep-header">
36000 <span class="method post">POST</span>
36001 <span class="ep-path">/api/confluence/post</span>
36002 <span class="auth-badge protected">Protected</span>
36003 <span class="ep-desc">Publish a scan report to Confluence</span>
36004 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
36005 </div>
36006 <div class="ep-body">
36007 <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>
36008 <p class="params-heading">Request Body (application/json)</p>
36009 <table class="params">
36010 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
36011 <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>
36012 <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>
36013 <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>
36014 </table>
36015 <details class="schema"><summary>Response schema</summary>
36016<div class="schema-block">// 200 OK
36017{ "ok": true, "page_id": string }
36018
36019// 400 / 502 on error
36020{ "ok": false, "error": string }</div></details>
36021 <p class="curl-heading">Example</p>
36022 <div class="curl-wrap">
36023 <pre class="curl-block" data-curl-id="c-cf-post">curl -X POST \
36024 -H "Authorization: Bearer $SLOC_API_KEY" \
36025 -H "Content-Type: application/json" \
36026 -d '{"run_id":"<uuid>","page_title":"SLOC Report 2025-05-10"}' \
36027 <span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/post</pre>
36028 <button class="curl-copy-btn" data-target="c-cf-post">Copy</button>
36029 </div>
36030 </div>
36031 </div>
36032
36033 <div class="ep-card">
36034 <div class="ep-header">
36035 <span class="method get">GET</span>
36036 <span class="ep-path">/api/confluence/wiki-markup</span>
36037 <span class="auth-badge protected">Protected</span>
36038 <span class="ep-desc">Get Confluence wiki markup for a run</span>
36039 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
36040 </div>
36041 <div class="ep-body">
36042 <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>
36043 <p class="params-heading">Query Parameters</p>
36044 <table class="params">
36045 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
36046 <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>
36047 </table>
36048 <p class="curl-heading">Example</p>
36049 <div class="curl-wrap">
36050 <pre class="curl-block" data-curl-id="c-cf-markup">curl -H "Authorization: Bearer $SLOC_API_KEY" \
36051 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/confluence/wiki-markup?run_id=<uuid>"</pre>
36052 <button class="curl-copy-btn" data-target="c-cf-markup">Copy</button>
36053 </div>
36054 </div>
36055 </div>
36056 </div>
36057
36058 <!-- Authentication -->
36059 <div class="section">
36060 <h2 class="section-title">Authentication</h2>
36061 <p class="webhook-note">These endpoints are always public. They manage browser session cookies used as an alternative to API key headers.</p>
36062
36063 <div class="ep-card">
36064 <div class="ep-header">
36065 <span class="method get">GET</span>
36066 <span class="ep-path">/auth/login</span>
36067 <span class="auth-badge public">Public</span>
36068 <span class="ep-desc">Login page</span>
36069 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
36070 </div>
36071 <div class="ep-body">
36072 <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>
36073 <p class="params-heading">Query Parameters</p>
36074 <table class="params">
36075 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
36076 <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>
36077 <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>
36078 </table>
36079 </div>
36080 </div>
36081
36082 <div class="ep-card">
36083 <div class="ep-header">
36084 <span class="method post">POST</span>
36085 <span class="ep-path">/auth/login</span>
36086 <span class="auth-badge public">Public</span>
36087 <span class="ep-desc">Submit credentials and get a session cookie</span>
36088 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
36089 </div>
36090 <div class="ep-body">
36091 <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>
36092 <p class="params-heading">Form Body (application/x-www-form-urlencoded)</p>
36093 <table class="params">
36094 <tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr>
36095 <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>
36096 <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>
36097 </table>
36098 <p class="curl-heading">Example</p>
36099 <div class="curl-wrap">
36100 <pre class="curl-block" data-curl-id="c-auth-login">curl -c cookies.txt -X POST \
36101 -d "key=$SLOC_API_KEY&next=/" \
36102 <span class="base-url-slot">http://127.0.0.1:4317</span>/auth/login</pre>
36103 <button class="curl-copy-btn" data-target="c-auth-login">Copy</button>
36104 </div>
36105 </div>
36106 </div>
36107 </div>
36108
36109 <!-- Coverage Suggestion -->
36110 <div class="section">
36111 <h2 class="section-title">Coverage Suggestion</h2>
36112
36113 <div class="ep-card">
36114 <div class="ep-header">
36115 <span class="method get">GET</span>
36116 <span class="ep-path">/api/suggest-coverage</span>
36117 <span class="auth-badge protected">Protected</span>
36118 <span class="ep-desc">Auto-detect a coverage file for a project root</span>
36119 <svg class="chevron" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
36120 </div>
36121 <div class="ep-body">
36122 <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>
36123 <p class="params-heading">Query Parameters</p>
36124 <table class="params">
36125 <tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
36126 <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>
36127 </table>
36128 <details class="schema"><summary>Response schema</summary>
36129<div class="schema-block">{
36130 "found": string | null, // absolute path to the coverage file, if detected
36131 "tool": string | null, // detected coverage tool (e.g. "cargo-llvm-cov", "jacoco", "pytest-cov")
36132 "hint": string | null // shell command to generate coverage if not found
36133}</div></details>
36134 <p class="curl-heading">Example</p>
36135 <div class="curl-wrap">
36136 <pre class="curl-block" data-curl-id="c-suggest-cov">curl -H "Authorization: Bearer $SLOC_API_KEY" \
36137 "<span class="base-url-slot">http://127.0.0.1:4317</span>/api/suggest-coverage?path=/path/to/repo"</pre>
36138 <button class="curl-copy-btn" data-target="c-suggest-cov">Copy</button>
36139 </div>
36140 </div>
36141 </div>
36142 </div>
36143
36144 </div>
36145
36146 <footer class="site-footer">
36147 local code analysis - metrics, history and reports
36148 · <em class="footer-mode sx-e01b0d98" id="footer-mode" >oxide-sloc v{{ version }} — Mode: Local</em>
36149 · Built by <a href="https://github.com/NimaShafie" target="_blank" rel="noopener">Nima Shafie</a>
36150 · <a href="https://github.com/oxide-sloc/oxide-sloc" target="_blank" rel="noopener">View on GitHub</a>
36151 · <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0-or-later</a>
36152 · <a href="/report-bug" rel="noopener">Report a Bug</a> · <a href="/api-docs" rel="noopener">REST API</a>
36153 </footer>
36154
36155 <script nonce="{{ csp_nonce }}">
36156 (function () {
36157 var base = window.location.origin;
36158 document.getElementById('base-url').textContent = base;
36159 document.querySelectorAll('.base-url-slot').forEach(function (el) {
36160 el.textContent = base;
36161 });
36162
36163 document.querySelectorAll('.ep-header').forEach(function (hdr) {
36164 hdr.addEventListener('click', function () {
36165 hdr.closest('.ep-card').classList.toggle('open');
36166 });
36167 });
36168
36169 document.querySelectorAll('.curl-copy-btn').forEach(function (btn) {
36170 btn.addEventListener('click', function () {
36171 var targetId = btn.dataset.target;
36172 var pre = document.querySelector('[data-curl-id="' + targetId + '"]');
36173 if (!pre) return;
36174 navigator.clipboard.writeText(pre.textContent).then(function () {
36175 btn.textContent = 'Copied!';
36176 btn.classList.add('copied');
36177 setTimeout(function () {
36178 btn.textContent = 'Copy';
36179 btn.classList.remove('copied');
36180 }, 2000);
36181 });
36182 });
36183 });
36184
36185 var storageKey = 'oxide-sloc-theme';
36186 try { document.body.classList.toggle('dark-theme', JSON.parse(localStorage.getItem(storageKey))); } catch (e) {}
36187 var themeBtn = document.getElementById('theme-toggle');
36188 if (themeBtn) {
36189 themeBtn.addEventListener('click', function () {
36190 var dark = document.body.classList.toggle('dark-theme');
36191 try { localStorage.setItem(storageKey, JSON.stringify(dark)); } catch (e) {}
36192 });
36193 }
36194 (function() {
36195 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'}];
36196 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);});}
36197 try{var sv=JSON.parse(localStorage.getItem('sloc-ns'));if(sv&&sv.a){ap(sv);}else{ap(S[0]);}}catch(e){ap(S[0]);}
36198 var btn=document.getElementById('settings-btn');if(!btn)return;
36199 var m=document.createElement('div');m.id='settings-modal';m.className='settings-modal';
36200 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 class="sx-dec3b281" ><div class="settings-modal-label sx-c500155b" >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>';
36201 document.body.appendChild(m);
36202 var g=document.getElementById('scheme-grid');
36203 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);});
36204 var cl=document.getElementById('settings-close');
36205 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);});})();
36206 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');});
36207 if(cl)cl.addEventListener('click',function(){m.classList.remove('open');});
36208 document.addEventListener('click',function(e){if(!m.contains(e.target)&&e.target!==btn)m.classList.remove('open');});
36209 })();
36210 (function randomizeWatermarks() {
36211 var wms = Array.prototype.slice.call(document.querySelectorAll('.background-watermarks img'));
36212 if (!wms.length) return;
36213 var placed = [];
36214 function tooClose(top, left) {
36215 for (var i = 0; i < placed.length; i++) {
36216 var dt = Math.abs(placed[i][0] - top), dl = Math.abs(placed[i][1] - left);
36217 if (dt < 16 && dl < 12) return true;
36218 }
36219 return false;
36220 }
36221 function pick(leftBand) {
36222 for (var attempt = 0; attempt < 50; attempt++) {
36223 var top = Math.random() * 88 + 2;
36224 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
36225 if (!tooClose(top, left)) { placed.push([top, left]); return [top, left]; }
36226 }
36227 var top = Math.random() * 88 + 2;
36228 var left = leftBand ? Math.random() * 24 + 1 : Math.random() * 24 + 74;
36229 placed.push([top, left]); return [top, left];
36230 }
36231 var half = Math.floor(wms.length / 2);
36232 wms.forEach(function (img, i) {
36233 var pos = pick(i < half);
36234 var size = Math.floor(Math.random() * 100 + 120);
36235 var rot = (Math.random() * 360).toFixed(1);
36236 var op = (Math.random() * 0.08 + 0.12).toFixed(2);
36237 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;
36238 });
36239 })();
36240 (function spawnCodeParticles() {
36241 var container = document.getElementById('code-particles');
36242 if (!container) return;
36243 var snippets = ['NUM sloc','fn analyze()','code_lines','0 mixed','blanks: NUM','// comment','pub fn run','use std::fs','Result<()>','let mut n = 0','git main','#[derive]','impl Scan','NUM physical','files: NUM','NUM comments','cargo build','Ok(run)','Vec<GEN>','match lang','fn main()','.rs .go .py','sloc_core','render_html','NUM code','HashMap<GEN, GN2>','Option<GEN>','&str','author AUT','last_change','ratio 0.CPX','complexity CPX','tokens: NUM','?; // try','let owner','commits: NUM','#[tokio::test]','assert_eq!','PCT% cover','blame AUT','churn PCT%','HEAD~CPX','dedup PCT%','cyclomatic CPX','impl Iterator','.await','+NUM -NM2','ownership','Span<GEN>','pub struct','trait Lang','unsafe','mixed: NUM','anyhow::Result','fn detect()','PathBuf','+NUM loc','coverage PCT%','physical: NUM','ULOC NUM','dup PCT%','let mut run']; var __GT=['Author','String','Token','FileRecord','Lang','u32','usize','PathBuf','Commit','Line','Span','Metric','Record','u64','char','Utf8','Ratio','Blame']; var __AN=['nima','core','web','cli','git','lang','ci','bot','sloc'];
36244 var count = 44;
36245 for (var i = 0; i < count; i++) {
36246 (function(idx) {
36247 var el = document.createElement('span');
36248 el.className = 'code-particle';
36249 el.textContent = snippets[idx % snippets.length].split('NUM').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('NM2').join(Math.floor(Math.random()*9700+140).toLocaleString()).split('GEN').join(__GT[Math.floor(Math.random()*__GT.length)]).split('GN2').join(__GT[Math.floor(Math.random()*__GT.length)]).split('AUT').join(__AN[Math.floor(Math.random()*__AN.length)]).split('CPX').join(''+Math.floor(Math.random()*40+1)).split('PCT').join(''+Math.floor(Math.random()*44+1));
36250 var left = Math.random() * 94 + 2;
36251 var top = Math.random() * 88 + 6;
36252 var dur = (Math.random() * 10 + 9).toFixed(1);
36253 var delay = (Math.random() * 18).toFixed(1);
36254 var rot = (Math.random() * 26 - 13).toFixed(1);
36255 var op = (Math.random() * 0.108 + 0.072).toFixed(3);
36256 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';
36257 container.appendChild(el);
36258 })(i);
36259 }
36260 })();
36261 }());
36262 </script>
36263</body>
36264</html>
36265"##,
36266 ext = "html"
36267)]
36268struct ApiDocsTemplate {
36269 has_api_key: bool,
36270 csp_nonce: String,
36271 version: &'static str,
36272}
36273
36274#[cfg(test)]
36275mod form_config_tests {
36276 use super::*;
36277 use sloc_config::{
36278 BinaryFileBehavior, BlankInBlockCommentPolicy, ContinuationLinePolicy, MixedLinePolicy,
36279 };
36280
36281 fn blank_form() -> AnalyzeForm {
36282 AnalyzeForm {
36283 path: ".".to_string(),
36284 git_repo: None,
36285 git_ref: None,
36286 mixed_line_policy: None,
36287 python_docstrings_as_comments: None,
36288 generated_file_detection: None,
36289 minified_file_detection: None,
36290 vendor_directory_detection: None,
36291 include_lockfiles: None,
36292 binary_file_behavior: None,
36293 output_dir: None,
36294 report_title: None,
36295 report_header_footer: None,
36296 include_globs: None,
36297 exclude_globs: None,
36298 submodule_breakdown: None,
36299 coverage_file: None,
36300 continuation_line_policy: None,
36301 blank_in_block_comment_policy: None,
36302 count_compiler_directives: None,
36303 style_col_threshold: None,
36304 style_analysis_enabled: None,
36305 style_score_threshold: None,
36306 style_lang_scope: None,
36307 cocomo_mode: None,
36308 complexity_alert: None,
36309 exclude_duplicates: None,
36310 activity_window: None,
36311 attribution: None,
36312 }
36313 }
36314
36315 fn apply(form: &AnalyzeForm) -> sloc_config::AppConfig {
36316 let mut cfg = sloc_config::AppConfig::default();
36317 apply_form_to_config(&mut cfg, form);
36318 cfg
36319 }
36320
36321 #[test]
36324 fn path_within_allowed_roots_accepts_inside_and_rejects_outside() {
36325 let root = tempfile::tempdir().unwrap();
36326 let inside = root.path().join("proj");
36327 std::fs::create_dir_all(&inside).unwrap();
36328 let inside_canon = std::fs::canonicalize(&inside).unwrap();
36329 let allowed = vec![root.path().to_path_buf()];
36330 assert!(
36331 path_within_allowed_roots(&inside_canon, &allowed),
36332 "a path under an allowed root must be accepted"
36333 );
36334
36335 let outside = tempfile::tempdir().unwrap();
36336 let outside_canon = std::fs::canonicalize(outside.path()).unwrap();
36337 assert!(
36338 !path_within_allowed_roots(&outside_canon, &allowed),
36339 "a path outside every allowed root must be rejected"
36340 );
36341 assert!(!path_within_allowed_roots(&inside_canon, &[]));
36343 }
36344
36345 #[test]
36348 fn attribution_on_by_default_and_toggle_disables() {
36349 let mut form = blank_form();
36350 assert!(apply(&form).analysis.attribution, "on unless disabled");
36351 form.attribution = Some("enabled".to_string());
36352 assert!(apply(&form).analysis.attribution, "'enabled' keeps it on");
36353 form.attribution = Some("disabled".to_string());
36354 assert!(
36355 !apply(&form).analysis.attribution,
36356 "'disabled' turns it off"
36357 );
36358 }
36359
36360 #[test]
36361 fn humanize_duration_formats_seconds_and_minutes() {
36362 assert_eq!(humanize_duration(0), "");
36363 assert_eq!(humanize_duration(3), "~3s");
36364 assert_eq!(humanize_duration(59), "~59s");
36365 assert_eq!(humanize_duration(60), "~1 min");
36366 assert_eq!(humanize_duration(61), "~2 min"); assert_eq!(humanize_duration(600), "~10 min");
36368 }
36369
36370 #[test]
36371 fn attrib_estimate_unknown_is_neutral() {
36372 let r = attrib_estimate_unknown();
36373 assert!(!r.estimate.is_git);
36374 assert!(r.estimate.recommend_attribution);
36375 assert_eq!(r.estimate.blameable_files, 0);
36376 assert!(r.estimated_label.is_empty());
36377 }
36378
36379 #[test]
36382 fn extract_long_commit_picks_super_repo_by_short_prefix() {
36383 let dir = tempfile::tempdir().unwrap();
36387 let path = dir.path().join("result.json");
36388 let body = r#"{
36389 "submodules": [
36390 { "git_commit_long": "aaaa111122223333444455556666777788889999" },
36391 { "git_commit_long": null }
36392 ],
36393 "git_commit_short": "4c2cd9b",
36394 "git_commit_long": "4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b"
36395}"#;
36396 std::fs::write(&path, body).unwrap();
36397 assert_eq!(
36398 super::extract_long_commit_from_json(&path, "4c2cd9b").as_deref(),
36399 Some("4c2cd9b2b46e4dc3efb86ccd560f33e6aa0be55b")
36400 );
36401 assert_eq!(super::extract_long_commit_from_json(&path, "deadbee"), None);
36403 assert_eq!(super::extract_long_commit_from_json(&path, ""), None);
36404 }
36405
36406 #[test]
36407 fn activity_window_defaults_on_when_field_blank() {
36408 let cfg = apply(&blank_form());
36410 assert_eq!(cfg.analysis.activity_window_days, Some(90));
36411 }
36412
36413 #[test]
36414 fn activity_window_override_sets_days() {
36415 let mut form = blank_form();
36416 form.activity_window = Some("30".to_string());
36417 let cfg = apply(&form);
36418 assert_eq!(cfg.analysis.activity_window_days, Some(30));
36419 }
36420
36421 #[test]
36422 fn activity_window_zero_disables() {
36423 let mut form = blank_form();
36425 form.activity_window = Some("0".to_string());
36426 let cfg = apply(&form);
36427 assert_eq!(cfg.analysis.activity_window_days, Some(0));
36428 }
36429
36430 #[test]
36433 fn python_docstrings_false_when_unchecked() {
36434 let cfg = apply(&blank_form());
36436 assert!(
36437 !cfg.analysis.python_docstrings_as_comments,
36438 "absent python_docstrings_as_comments must map to false"
36439 );
36440 }
36441
36442 #[test]
36443 fn python_docstrings_true_when_checked() {
36444 let mut form = blank_form();
36446 form.python_docstrings_as_comments = Some("on".to_string());
36447 let cfg = apply(&form);
36448 assert!(cfg.analysis.python_docstrings_as_comments);
36449 }
36450
36451 #[test]
36452 fn python_docstrings_true_for_any_non_none_value() {
36453 let mut form = blank_form();
36455 form.python_docstrings_as_comments = Some("true".to_string());
36456 assert!(apply(&form).analysis.python_docstrings_as_comments);
36457 }
36458
36459 #[test]
36462 fn submodule_breakdown_false_when_unchecked() {
36463 let cfg = apply(&blank_form());
36464 assert!(
36465 !cfg.discovery.submodule_breakdown,
36466 "absent submodule_breakdown must map to false"
36467 );
36468 }
36469
36470 #[test]
36471 fn submodule_breakdown_true_when_value_enabled() {
36472 let mut form = blank_form();
36473 form.submodule_breakdown = Some("enabled".to_string());
36474 assert!(apply(&form).discovery.submodule_breakdown);
36475 }
36476
36477 #[test]
36478 fn submodule_breakdown_false_for_wrong_value() {
36479 let mut form = blank_form();
36481 form.submodule_breakdown = Some("on".to_string());
36482 assert!(
36483 !apply(&form).discovery.submodule_breakdown,
36484 "submodule_breakdown only becomes true for the exact value 'enabled'"
36485 );
36486 }
36487
36488 #[test]
36491 fn generated_detection_true_when_enabled() {
36492 let mut form = blank_form();
36493 form.generated_file_detection = Some("enabled".to_string());
36494 assert!(apply(&form).analysis.generated_file_detection);
36495 }
36496
36497 #[test]
36498 fn generated_detection_false_when_disabled() {
36499 let mut form = blank_form();
36500 form.generated_file_detection = Some("disabled".to_string());
36501 assert!(!apply(&form).analysis.generated_file_detection);
36502 }
36503
36504 #[test]
36505 fn generated_detection_true_when_absent() {
36506 assert!(
36508 apply(&blank_form()).analysis.generated_file_detection,
36509 "absent field must default to true (detection on)"
36510 );
36511 }
36512
36513 #[test]
36516 fn minified_detection_false_when_disabled() {
36517 let mut form = blank_form();
36518 form.minified_file_detection = Some("disabled".to_string());
36519 assert!(!apply(&form).analysis.minified_file_detection);
36520 }
36521
36522 #[test]
36523 fn minified_detection_true_when_enabled() {
36524 let mut form = blank_form();
36525 form.minified_file_detection = Some("enabled".to_string());
36526 assert!(apply(&form).analysis.minified_file_detection);
36527 }
36528
36529 #[test]
36530 fn minified_detection_true_when_absent() {
36531 assert!(apply(&blank_form()).analysis.minified_file_detection);
36532 }
36533
36534 #[test]
36537 fn vendor_detection_false_when_disabled() {
36538 let mut form = blank_form();
36539 form.vendor_directory_detection = Some("disabled".to_string());
36540 assert!(!apply(&form).analysis.vendor_directory_detection);
36541 }
36542
36543 #[test]
36544 fn vendor_detection_true_when_enabled() {
36545 let mut form = blank_form();
36546 form.vendor_directory_detection = Some("enabled".to_string());
36547 assert!(apply(&form).analysis.vendor_directory_detection);
36548 }
36549
36550 #[test]
36551 fn vendor_detection_true_when_absent() {
36552 assert!(apply(&blank_form()).analysis.vendor_directory_detection);
36553 }
36554
36555 #[test]
36558 fn lockfiles_false_when_absent() {
36559 assert!(!apply(&blank_form()).analysis.include_lockfiles);
36561 }
36562
36563 #[test]
36564 fn lockfiles_false_when_disabled() {
36565 let mut form = blank_form();
36566 form.include_lockfiles = Some("disabled".to_string());
36567 assert!(!apply(&form).analysis.include_lockfiles);
36568 }
36569
36570 #[test]
36571 fn lockfiles_true_when_enabled() {
36572 let mut form = blank_form();
36573 form.include_lockfiles = Some("enabled".to_string());
36574 assert!(apply(&form).analysis.include_lockfiles);
36575 }
36576
36577 #[test]
36580 fn compiler_directives_true_when_absent() {
36581 assert!(
36582 apply(&blank_form()).analysis.count_compiler_directives,
36583 "absent count_compiler_directives must default to true"
36584 );
36585 }
36586
36587 #[test]
36588 fn compiler_directives_true_when_enabled() {
36589 let mut form = blank_form();
36590 form.count_compiler_directives = Some("enabled".to_string());
36591 assert!(apply(&form).analysis.count_compiler_directives);
36592 }
36593
36594 #[test]
36595 fn compiler_directives_false_when_disabled() {
36596 let mut form = blank_form();
36597 form.count_compiler_directives = Some("disabled".to_string());
36598 assert!(!apply(&form).analysis.count_compiler_directives);
36599 }
36600
36601 #[test]
36604 fn mixed_policy_unchanged_when_absent() {
36605 assert_eq!(
36607 apply(&blank_form()).analysis.mixed_line_policy,
36608 MixedLinePolicy::CodeOnly
36609 );
36610 }
36611
36612 #[test]
36613 fn mixed_policy_code_only() {
36614 let mut form = blank_form();
36615 form.mixed_line_policy = Some(MixedLinePolicy::CodeOnly);
36616 assert_eq!(
36617 apply(&form).analysis.mixed_line_policy,
36618 MixedLinePolicy::CodeOnly
36619 );
36620 }
36621
36622 #[test]
36623 fn mixed_policy_code_and_comment() {
36624 let mut form = blank_form();
36625 form.mixed_line_policy = Some(MixedLinePolicy::CodeAndComment);
36626 assert_eq!(
36627 apply(&form).analysis.mixed_line_policy,
36628 MixedLinePolicy::CodeAndComment
36629 );
36630 }
36631
36632 #[test]
36633 fn mixed_policy_comment_only() {
36634 let mut form = blank_form();
36635 form.mixed_line_policy = Some(MixedLinePolicy::CommentOnly);
36636 assert_eq!(
36637 apply(&form).analysis.mixed_line_policy,
36638 MixedLinePolicy::CommentOnly
36639 );
36640 }
36641
36642 #[test]
36643 fn mixed_policy_separate_mixed_category() {
36644 let mut form = blank_form();
36645 form.mixed_line_policy = Some(MixedLinePolicy::SeparateMixedCategory);
36646 assert_eq!(
36647 apply(&form).analysis.mixed_line_policy,
36648 MixedLinePolicy::SeparateMixedCategory
36649 );
36650 }
36651
36652 #[test]
36655 fn binary_behavior_skip_when_absent() {
36656 assert_eq!(
36657 apply(&blank_form()).analysis.binary_file_behavior,
36658 BinaryFileBehavior::Skip
36659 );
36660 }
36661
36662 #[test]
36663 fn binary_behavior_skip() {
36664 let mut form = blank_form();
36665 form.binary_file_behavior = Some(BinaryFileBehavior::Skip);
36666 assert_eq!(
36667 apply(&form).analysis.binary_file_behavior,
36668 BinaryFileBehavior::Skip
36669 );
36670 }
36671
36672 #[test]
36673 fn binary_behavior_fail() {
36674 let mut form = blank_form();
36675 form.binary_file_behavior = Some(BinaryFileBehavior::Fail);
36676 assert_eq!(
36677 apply(&form).analysis.binary_file_behavior,
36678 BinaryFileBehavior::Fail
36679 );
36680 }
36681
36682 #[test]
36685 fn continuation_policy_each_physical_when_absent() {
36686 assert_eq!(
36687 apply(&blank_form()).analysis.continuation_line_policy,
36688 ContinuationLinePolicy::EachPhysicalLine
36689 );
36690 }
36691
36692 #[test]
36693 fn continuation_policy_collapse_to_logical() {
36694 let mut form = blank_form();
36695 form.continuation_line_policy = Some(ContinuationLinePolicy::CollapseToLogical);
36696 assert_eq!(
36697 apply(&form).analysis.continuation_line_policy,
36698 ContinuationLinePolicy::CollapseToLogical
36699 );
36700 }
36701
36702 #[test]
36705 fn blank_in_block_comment_count_as_comment_when_absent() {
36706 assert_eq!(
36707 apply(&blank_form()).analysis.blank_in_block_comment_policy,
36708 BlankInBlockCommentPolicy::CountAsComment
36709 );
36710 }
36711
36712 #[test]
36713 fn blank_in_block_comment_count_as_blank() {
36714 let mut form = blank_form();
36715 form.blank_in_block_comment_policy = Some(BlankInBlockCommentPolicy::CountAsBlank);
36716 assert_eq!(
36717 apply(&form).analysis.blank_in_block_comment_policy,
36718 BlankInBlockCommentPolicy::CountAsBlank
36719 );
36720 }
36721
36722 #[test]
36725 fn style_threshold_80() {
36726 let mut form = blank_form();
36727 form.style_col_threshold = Some("80".to_string());
36728 assert_eq!(apply(&form).analysis.style_col_threshold, 80);
36729 }
36730
36731 #[test]
36732 fn style_threshold_100() {
36733 let mut form = blank_form();
36734 form.style_col_threshold = Some("100".to_string());
36735 assert_eq!(apply(&form).analysis.style_col_threshold, 100);
36736 }
36737
36738 #[test]
36739 fn style_threshold_120() {
36740 let mut form = blank_form();
36741 form.style_col_threshold = Some("120".to_string());
36742 assert_eq!(apply(&form).analysis.style_col_threshold, 120);
36743 }
36744
36745 #[test]
36746 fn style_threshold_invalid_value_leaves_default() {
36747 let mut cfg = sloc_config::AppConfig::default();
36749 let mut form = blank_form();
36750 form.style_col_threshold = Some("42".to_string());
36751 apply_form_to_config(&mut cfg, &form);
36752 assert_eq!(
36753 cfg.analysis.style_col_threshold, 80,
36754 "invalid threshold must not change config"
36755 );
36756 }
36757
36758 #[test]
36759 fn style_threshold_non_numeric_leaves_default() {
36760 let mut cfg = sloc_config::AppConfig::default();
36761 let mut form = blank_form();
36762 form.style_col_threshold = Some("large".to_string());
36763 apply_form_to_config(&mut cfg, &form);
36764 assert_eq!(cfg.analysis.style_col_threshold, 80);
36765 }
36766
36767 #[test]
36768 fn style_threshold_zero_leaves_default() {
36769 let mut cfg = sloc_config::AppConfig::default();
36770 let mut form = blank_form();
36771 form.style_col_threshold = Some("0".to_string());
36772 apply_form_to_config(&mut cfg, &form);
36773 assert_eq!(cfg.analysis.style_col_threshold, 80);
36774 }
36775
36776 #[test]
36777 fn style_threshold_absent_leaves_default() {
36778 assert_eq!(apply(&blank_form()).analysis.style_col_threshold, 80);
36779 }
36780
36781 #[test]
36784 fn style_score_threshold_zero_when_absent() {
36785 assert_eq!(apply(&blank_form()).analysis.style_score_threshold, 0);
36786 }
36787
36788 #[test]
36789 fn style_score_threshold_set_to_valid_value() {
36790 let mut form = blank_form();
36791 form.style_score_threshold = Some("70".to_string());
36792 assert_eq!(apply(&form).analysis.style_score_threshold, 70);
36793 }
36794
36795 #[test]
36796 fn style_score_threshold_clamps_to_100_when_over() {
36797 let mut form = blank_form();
36799 form.style_score_threshold = Some("200".to_string());
36800 assert_eq!(
36801 apply(&form).analysis.style_score_threshold,
36802 100,
36803 "style_score_threshold must be clamped to 100 when the submitted value exceeds it"
36804 );
36805 }
36806
36807 #[test]
36810 fn coverage_file_none_when_absent() {
36811 assert!(apply(&blank_form()).analysis.coverage_file.is_none());
36812 }
36813
36814 #[test]
36815 fn coverage_file_none_when_whitespace_only() {
36816 let mut form = blank_form();
36817 form.coverage_file = Some(" ".to_string());
36818 assert!(
36819 apply(&form).analysis.coverage_file.is_none(),
36820 "whitespace-only coverage_file must be treated as None"
36821 );
36822 }
36823
36824 #[test]
36825 fn coverage_file_set_when_non_empty() {
36826 let mut form = blank_form();
36827 form.coverage_file = Some("coverage/lcov.info".to_string());
36828 assert_eq!(
36829 apply(&form).analysis.coverage_file,
36830 Some(std::path::PathBuf::from("coverage/lcov.info"))
36831 );
36832 }
36833
36834 #[test]
36835 fn coverage_file_trims_whitespace() {
36836 let mut form = blank_form();
36837 form.coverage_file = Some(" coverage/lcov.info ".to_string());
36838 assert_eq!(
36839 apply(&form).analysis.coverage_file,
36840 Some(std::path::PathBuf::from("coverage/lcov.info"))
36841 );
36842 }
36843
36844 #[test]
36847 fn report_title_unchanged_when_absent() {
36848 let original = sloc_config::AppConfig::default().reporting.report_title;
36849 assert_eq!(apply(&blank_form()).reporting.report_title, original);
36850 }
36851
36852 #[test]
36853 fn report_title_unchanged_when_whitespace_only() {
36854 let original = sloc_config::AppConfig::default().reporting.report_title;
36855 let mut form = blank_form();
36856 form.report_title = Some(" ".to_string());
36857 assert_eq!(
36858 apply(&form).reporting.report_title,
36859 original,
36860 "whitespace-only title must not overwrite the default"
36861 );
36862 }
36863
36864 #[test]
36865 fn report_title_updated_and_trimmed() {
36866 let mut form = blank_form();
36867 form.report_title = Some(" My Project ".to_string());
36868 assert_eq!(apply(&form).reporting.report_title, "My Project");
36869 }
36870
36871 #[test]
36874 fn header_footer_none_when_absent() {
36875 assert!(
36876 apply(&blank_form())
36877 .reporting
36878 .report_header_footer
36879 .is_none()
36880 );
36881 }
36882
36883 #[test]
36884 fn header_footer_none_when_whitespace_only() {
36885 let mut form = blank_form();
36886 form.report_header_footer = Some(" ".to_string());
36887 assert!(apply(&form).reporting.report_header_footer.is_none());
36888 }
36889
36890 #[test]
36891 fn header_footer_set_and_trimmed() {
36892 let mut form = blank_form();
36893 form.report_header_footer = Some(" Confidential — Internal Use ".to_string());
36894 assert_eq!(
36895 apply(&form).reporting.report_header_footer,
36896 Some("Confidential — Internal Use".to_string())
36897 );
36898 }
36899
36900 #[test]
36903 fn include_globs_empty_when_absent() {
36904 assert!(apply(&blank_form()).discovery.include_globs.is_empty());
36905 }
36906
36907 #[test]
36908 fn include_globs_newline_separated() {
36909 let mut form = blank_form();
36910 form.include_globs = Some("src/**/*.rs\ntests/**/*.rs".to_string());
36911 assert_eq!(
36912 apply(&form).discovery.include_globs,
36913 vec!["src/**/*.rs", "tests/**/*.rs"]
36914 );
36915 }
36916
36917 #[test]
36918 fn exclude_globs_comma_separated() {
36919 let mut form = blank_form();
36920 form.exclude_globs = Some("vendor/**,node_modules/**".to_string());
36921 assert_eq!(
36922 apply(&form).discovery.exclude_globs,
36923 vec!["vendor/**", "node_modules/**"]
36924 );
36925 }
36926
36927 #[test]
36928 fn globs_mixed_separators() {
36929 let mut form = blank_form();
36930 form.exclude_globs = Some("a/**\nb/**,c/**".to_string());
36931 assert_eq!(
36932 apply(&form).discovery.exclude_globs,
36933 vec!["a/**", "b/**", "c/**"]
36934 );
36935 }
36936
36937 #[test]
36940 fn split_patterns_none_is_empty() {
36941 assert!(split_patterns(None).is_empty());
36942 }
36943
36944 #[test]
36945 fn split_patterns_empty_string_is_empty() {
36946 assert!(split_patterns(Some("")).is_empty());
36947 }
36948
36949 #[test]
36950 fn split_patterns_whitespace_only_is_empty() {
36951 assert!(split_patterns(Some(" \n \n ")).is_empty());
36952 }
36953
36954 #[test]
36955 fn split_patterns_newlines() {
36956 assert_eq!(
36957 split_patterns(Some("a/**\nb/**\nc/**")),
36958 vec!["a/**", "b/**", "c/**"]
36959 );
36960 }
36961
36962 #[test]
36963 fn split_patterns_commas() {
36964 assert_eq!(
36965 split_patterns(Some("a/**,b/**,c/**")),
36966 vec!["a/**", "b/**", "c/**"]
36967 );
36968 }
36969
36970 #[test]
36971 fn split_patterns_mixed() {
36972 assert_eq!(
36973 split_patterns(Some("a/**\nb/**,c/**")),
36974 vec!["a/**", "b/**", "c/**"]
36975 );
36976 }
36977
36978 #[test]
36979 fn split_patterns_trims_whitespace() {
36980 assert_eq!(
36981 split_patterns(Some(" a/** \n b/** ")),
36982 vec!["a/**", "b/**"]
36983 );
36984 }
36985
36986 #[test]
36987 fn split_patterns_filters_empty_entries() {
36988 assert_eq!(split_patterns(Some(",\n,,a/**,,\n")), vec!["a/**"]);
36989 }
36990
36991 #[test]
36992 fn split_patterns_single_entry() {
36993 assert_eq!(split_patterns(Some("src/**")), vec!["src/**"]);
36994 }
36995}
36996
36997#[cfg(test)]
36998mod utility_tests {
36999 use super::*;
37000 use std::net::IpAddr;
37001 use std::time::Duration;
37002
37003 #[test]
37006 fn sanitize_simple_name() {
37007 assert_eq!(sanitize_project_label("myrepo"), "myrepo");
37008 }
37009
37010 #[test]
37011 fn sanitize_uppercased_lowercased() {
37012 assert_eq!(sanitize_project_label("MyRepo"), "myrepo");
37013 }
37014
37015 #[test]
37016 fn sanitize_path_extracts_filename() {
37017 assert_eq!(
37018 sanitize_project_label("/home/user/my-project"),
37019 "my-project"
37020 );
37021 }
37022
37023 #[test]
37024 fn sanitize_path_uses_last_component() {
37025 assert_eq!(sanitize_project_label("/a/b/c/d"), "d");
37026 }
37027
37028 #[test]
37029 fn sanitize_spaces_become_hyphens() {
37030 assert_eq!(sanitize_project_label("my project"), "my-project");
37031 }
37032
37033 #[test]
37034 fn sanitize_non_ascii_become_hyphens() {
37035 assert_eq!(sanitize_project_label("proj\u{00e9}ct"), "proj-ct");
37036 }
37037
37038 #[test]
37039 fn sanitize_all_special_chars_gives_project() {
37040 assert_eq!(sanitize_project_label("!@#$%^"), "project");
37041 }
37042
37043 #[test]
37044 fn sanitize_empty_string_gives_project() {
37045 assert_eq!(sanitize_project_label(""), "project");
37046 }
37047
37048 #[test]
37049 fn sanitize_leading_trailing_hyphens_stripped() {
37050 assert_eq!(sanitize_project_label("!myrepo!"), "myrepo");
37051 }
37052
37053 #[test]
37054 fn sanitize_alphanumeric_preserved() {
37055 assert_eq!(sanitize_project_label("repo123"), "repo123");
37056 }
37057
37058 #[test]
37059 fn sanitize_dots_become_hyphens() {
37060 assert_eq!(sanitize_project_label("my.repo.name"), "my-repo-name");
37061 }
37062
37063 #[test]
37064 fn sanitize_mixed_slashes_uses_filename() {
37065 assert_eq!(sanitize_project_label("project-name"), "project-name");
37067 }
37068
37069 #[test]
37072 fn rate_limiter_allows_first_request() {
37073 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_hours(1));
37074 let ip: IpAddr = "127.0.0.1".parse().unwrap();
37075 assert!(rl.is_allowed(ip));
37076 }
37077
37078 #[test]
37079 fn rate_limiter_blocks_after_limit_reached() {
37080 let rl = IpRateLimiter::new(Duration::from_mins(1), 3, 5, Duration::from_hours(1));
37081 let ip: IpAddr = "10.0.0.1".parse().unwrap();
37082 assert!(rl.is_allowed(ip));
37083 assert!(rl.is_allowed(ip));
37084 assert!(rl.is_allowed(ip));
37085 assert!(!rl.is_allowed(ip), "4th request must be blocked");
37086 }
37087
37088 #[test]
37089 fn rate_limiter_allows_requests_up_to_limit() {
37090 let rl = IpRateLimiter::new(Duration::from_mins(1), 5, 5, Duration::from_hours(1));
37091 let ip: IpAddr = "10.0.0.2".parse().unwrap();
37092 for _ in 0..5 {
37093 assert!(rl.is_allowed(ip));
37094 }
37095 assert!(!rl.is_allowed(ip), "6th request must be blocked");
37096 }
37097
37098 #[test]
37099 fn rate_limiter_different_ips_are_independent() {
37100 let rl = IpRateLimiter::new(Duration::from_mins(1), 1, 5, Duration::from_hours(1));
37101 let ip1: IpAddr = "192.168.1.1".parse().unwrap();
37102 let ip2: IpAddr = "192.168.1.2".parse().unwrap();
37103 assert!(rl.is_allowed(ip1));
37104 assert!(!rl.is_allowed(ip1), "ip1 blocked after limit");
37105 assert!(rl.is_allowed(ip2), "ip2 must be independent");
37106 }
37107
37108 #[test]
37109 fn rate_limiter_auth_failure_not_locked_below_threshold() {
37110 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
37111 let ip: IpAddr = "10.0.0.3".parse().unwrap();
37112 rl.record_auth_failure(ip);
37113 rl.record_auth_failure(ip);
37114 assert!(
37115 !rl.is_auth_locked_out(ip),
37116 "not locked at 2 failures when threshold is 3"
37117 );
37118 }
37119
37120 #[test]
37121 fn rate_limiter_auth_failure_locked_at_threshold() {
37122 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 3, Duration::from_hours(1));
37123 let ip: IpAddr = "10.0.0.4".parse().unwrap();
37124 rl.record_auth_failure(ip);
37125 rl.record_auth_failure(ip);
37126 rl.record_auth_failure(ip);
37127 assert!(rl.is_auth_locked_out(ip), "must be locked after 3 failures");
37128 }
37129
37130 #[test]
37131 fn rate_limiter_auth_failure_different_ips_independent() {
37132 let rl = IpRateLimiter::new(Duration::from_mins(1), 100, 2, Duration::from_hours(1));
37133 let ip1: IpAddr = "10.0.1.1".parse().unwrap();
37134 let ip2: IpAddr = "10.0.1.2".parse().unwrap();
37135 rl.record_auth_failure(ip1);
37136 rl.record_auth_failure(ip1);
37137 assert!(rl.is_auth_locked_out(ip1));
37138 assert!(!rl.is_auth_locked_out(ip2), "ip2 must not be locked");
37139 }
37140
37141 #[test]
37142 fn rate_limiter_high_limit_never_blocks_normal_traffic() {
37143 let rl = IpRateLimiter::new(Duration::from_mins(1), 1000, 10, Duration::from_hours(1));
37144 let ip: IpAddr = "127.0.0.2".parse().unwrap();
37145 for _ in 0..100 {
37146 assert!(rl.is_allowed(ip));
37147 }
37148 }
37149
37150 #[test]
37153 fn strip_unc_plain_path_unchanged() {
37154 let p = PathBuf::from("C:\\Users\\user\\project");
37155 let result = strip_unc_prefix(p.clone());
37156 assert_eq!(result, p);
37157 }
37158
37159 #[test]
37160 fn strip_unc_with_drive_prefix_stripped() {
37161 let p = PathBuf::from(r"\\?\C:\Users\user\project");
37162 let result = strip_unc_prefix(p);
37163 assert_eq!(result, PathBuf::from(r"C:\Users\user\project"));
37164 }
37165
37166 #[test]
37167 fn strip_unc_with_network_prefix_stripped() {
37168 let p = PathBuf::from(r"\\?\UNC\server\share\dir");
37169 let result = strip_unc_prefix(p);
37170 assert_eq!(result, PathBuf::from(r"\\server\share\dir"));
37171 }
37172
37173 #[test]
37174 fn strip_unc_linux_path_unchanged() {
37175 let p = PathBuf::from("/home/user/project");
37176 let result = strip_unc_prefix(p.clone());
37177 assert_eq!(result, p);
37178 }
37179
37180 #[test]
37183 fn remote_to_commit_url_github_https() {
37184 let url = remote_to_commit_url("https://github.com/owner/repo.git", "abc1234");
37185 assert_eq!(
37186 url,
37187 Some("https://github.com/owner/repo/commit/abc1234".to_owned())
37188 );
37189 }
37190
37191 #[test]
37192 fn remote_to_commit_url_github_ssh() {
37193 let url = remote_to_commit_url("git@github.com:owner/repo.git", "abc1234");
37194 assert_eq!(
37195 url,
37196 Some("https://github.com/owner/repo/commit/abc1234".to_owned())
37197 );
37198 }
37199
37200 #[test]
37201 fn remote_to_commit_url_gitlab_uses_dash_commit() {
37202 let url = remote_to_commit_url("https://gitlab.com/group/repo.git", "deadbeef");
37203 assert_eq!(
37204 url,
37205 Some("https://gitlab.com/group/repo/-/commit/deadbeef".to_owned())
37206 );
37207 }
37208
37209 #[test]
37210 fn remote_to_commit_url_bitbucket_uses_commits() {
37211 let url = remote_to_commit_url("https://bitbucket.org/workspace/repo.git", "cafebabe");
37212 assert_eq!(
37213 url,
37214 Some("https://bitbucket.org/workspace/repo/commits/cafebabe".to_owned())
37215 );
37216 }
37217
37218 #[test]
37219 fn remote_to_commit_url_unknown_scheme_returns_none() {
37220 let url = remote_to_commit_url("ftp://example.com/repo.git", "abc");
37221 assert!(url.is_none());
37222 }
37223
37224 #[test]
37225 fn remote_to_commit_url_ssh_gitlab() {
37226 let url = remote_to_commit_url("git@gitlab.com:group/repo.git", "sha123");
37227 assert!(url.is_some());
37228 let u = url.unwrap();
37229 assert!(
37230 u.contains("/-/commit/sha123"),
37231 "gitlab ssh must use /-/commit/"
37232 );
37233 }
37234
37235 #[test]
37238 fn git_clone_dest_github_url_produces_safe_name() {
37239 let dir = PathBuf::from("/tmp/clones");
37240 let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
37241 let name = dest.file_name().unwrap().to_string_lossy();
37242 assert!(!name.is_empty());
37243 assert!(
37244 name.chars()
37245 .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.'),
37246 "clone dest must only contain safe chars, got: {name}"
37247 );
37248 }
37249
37250 #[test]
37251 fn git_clone_dest_is_inside_clones_dir() {
37252 let dir = PathBuf::from("/tmp/clones");
37253 let dest = git_clone_dest("https://github.com/owner/repo.git", &dir);
37254 assert!(
37255 dest.starts_with(&dir),
37256 "clone dest must be inside clones_dir"
37257 );
37258 }
37259
37260 #[test]
37261 fn git_clone_dest_truncates_to_80_chars_max() {
37262 let long_url = "https://github.com/".to_string() + &"a".repeat(200);
37263 let dir = PathBuf::from("/tmp/clones");
37264 let dest = git_clone_dest(&long_url, &dir);
37265 let name = dest.file_name().unwrap().to_string_lossy();
37266 assert!(
37267 name.len() <= 80,
37268 "clone dest name must be at most 80 chars, got {} chars: {name}",
37269 name.len()
37270 );
37271 }
37272
37273 #[test]
37274 fn git_clone_dest_special_chars_replaced_with_underscore() {
37275 let dir = PathBuf::from("/tmp/clones");
37276 let dest = git_clone_dest("git@github.com:owner/repo.git", &dir);
37277 let name = dest.file_name().unwrap().to_string_lossy();
37278 assert!(
37279 !name.contains('@') && !name.contains(':') && !name.contains('/'),
37280 "special chars must be replaced in clone dest, got: {name}"
37281 );
37282 }
37283
37284 #[test]
37285 fn git_clone_dest_different_urls_differ() {
37286 let dir = PathBuf::from("/tmp/clones");
37287 let a = git_clone_dest("https://github.com/owner/repo-a.git", &dir);
37288 let b = git_clone_dest("https://github.com/owner/repo-b.git", &dir);
37289 assert_ne!(
37290 a, b,
37291 "different repos must produce different clone dest names"
37292 );
37293 }
37294
37295 #[test]
37296 fn git_clone_dest_same_url_same_result() {
37297 let dir = PathBuf::from("/tmp/clones");
37298 let url = "https://github.com/owner/repo.git";
37299 assert_eq!(
37300 git_clone_dest(url, &dir),
37301 git_clone_dest(url, &dir),
37302 "same URL must always give same clone dest"
37303 );
37304 }
37305
37306 #[test]
37309 fn fmt_delta_positive_has_plus_prefix() {
37310 assert_eq!(fmt_delta(5), "+5");
37311 }
37312
37313 #[test]
37314 fn fmt_delta_negative_no_plus_prefix() {
37315 assert_eq!(fmt_delta(-3), "-3");
37316 }
37317
37318 #[test]
37319 fn fmt_delta_zero() {
37320 assert_eq!(fmt_delta(0), "0");
37321 }
37322
37323 #[test]
37326 fn delta_class_positive_is_pos() {
37327 assert_eq!(delta_class(1), "pos");
37328 }
37329
37330 #[test]
37331 fn delta_class_negative_is_neg() {
37332 assert_eq!(delta_class(-1), "neg");
37333 }
37334
37335 #[test]
37336 fn delta_class_zero_is_zero_class() {
37337 assert_eq!(delta_class(0), "zero");
37338 }
37339
37340 #[test]
37343 fn fmt_pct_zero_baseline_returns_em_dash() {
37344 assert_eq!(fmt_pct(100, 0), "\u{2014}");
37345 }
37346
37347 #[test]
37348 fn fmt_pct_positive_delta_has_plus_sign() {
37349 let result = fmt_pct(10, 100);
37350 assert!(result.starts_with('+'), "expected + prefix, got: {result}");
37351 }
37352
37353 #[test]
37354 fn fmt_pct_negative_delta_no_plus_sign() {
37355 let result = fmt_pct(-10, 100);
37356 assert!(!result.starts_with('+'), "unexpected + in: {result}");
37357 assert!(result.contains('%'));
37358 }
37359
37360 #[test]
37361 fn fmt_pct_near_zero_returns_pm_zero() {
37362 assert_eq!(fmt_pct(0, 1000), "\u{00b1}0%");
37363 }
37364
37365 #[test]
37368 fn summary_delta_no_prev_returns_dash_na() {
37369 let (display, class) = summary_delta(10, None);
37370 assert_eq!(display, "\u{2014}");
37371 assert_eq!(class, "na");
37372 }
37373
37374 #[test]
37375 fn summary_delta_increase_is_positive() {
37376 let (display, class) = summary_delta(15, Some(10));
37377 assert_eq!(display, "+5");
37378 assert_eq!(class, "pos");
37379 }
37380
37381 #[test]
37382 fn summary_delta_decrease_is_negative() {
37383 let (display, class) = summary_delta(5, Some(10));
37384 assert_eq!(display, "-5");
37385 assert_eq!(class, "neg");
37386 }
37387
37388 #[test]
37391 fn nth_weekday_first_monday_jan_2024_is_in_first_week() {
37392 use chrono::Datelike;
37393 let d = nth_weekday_of_month(2024, 1, chrono::Weekday::Mon, 1);
37394 assert_eq!(d.year(), 2024);
37395 assert_eq!(d.month(), 1);
37396 assert_eq!(d.weekday(), chrono::Weekday::Mon);
37397 assert!(d.day() <= 7);
37398 }
37399
37400 #[test]
37401 fn nth_weekday_second_sunday_march_2024_is_10th() {
37402 use chrono::Datelike;
37403 let d = nth_weekday_of_month(2024, 3, chrono::Weekday::Sun, 2);
37404 assert_eq!(d.weekday(), chrono::Weekday::Sun);
37405 assert_eq!(d.month(), 3);
37406 assert_eq!(d.day(), 10, "2nd Sunday in March 2024 is the 10th");
37407 }
37408
37409 #[test]
37412 fn is_pacific_dst_july_is_true() {
37413 let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
37414 assert!(is_pacific_dst(dt), "July must be PDT");
37415 }
37416
37417 #[test]
37418 fn is_pacific_dst_january_is_false() {
37419 let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
37420 assert!(!is_pacific_dst(dt), "January must be PST");
37421 }
37422
37423 #[test]
37424 fn fmt_la_time_summer_shows_pdt() {
37425 let dt: chrono::DateTime<chrono::Utc> = "2024-07-15T20:00:00Z".parse().unwrap();
37426 let result = fmt_la_time(dt);
37427 assert!(
37428 result.ends_with("PDT"),
37429 "summer must use PDT, got: {result}"
37430 );
37431 }
37432
37433 #[test]
37434 fn fmt_la_time_winter_shows_pst() {
37435 let dt: chrono::DateTime<chrono::Utc> = "2024-01-15T20:00:00Z".parse().unwrap();
37436 let result = fmt_la_time(dt);
37437 assert!(
37438 result.ends_with("PST"),
37439 "winter must use PST, got: {result}"
37440 );
37441 }
37442
37443 #[test]
37444 fn fmt_la_time_meta_summer_shows_pdt() {
37445 let dt: chrono::DateTime<chrono::Utc> = "2024-08-01T12:00:00Z".parse().unwrap();
37446 let result = fmt_la_time_meta(dt);
37447 assert!(
37448 result.ends_with("PDT"),
37449 "meta summer must use PDT, got: {result}"
37450 );
37451 }
37452
37453 #[test]
37454 fn fmt_la_time_meta_winter_shows_pst() {
37455 let dt: chrono::DateTime<chrono::Utc> = "2024-12-01T12:00:00Z".parse().unwrap();
37456 let result = fmt_la_time_meta(dt);
37457 assert!(
37458 result.ends_with("PST"),
37459 "meta winter must use PST, got: {result}"
37460 );
37461 }
37462
37463 #[test]
37466 fn fmt_git_date_valid_iso_returns_some() {
37467 assert!(fmt_git_date("2024-07-15T20:00:00Z").is_some());
37468 }
37469
37470 #[test]
37471 fn fmt_git_date_invalid_returns_none() {
37472 assert!(fmt_git_date("not-a-date").is_none());
37473 }
37474
37475 #[test]
37478 fn format_number_zero() {
37479 assert_eq!(format_number(0), "0");
37480 }
37481
37482 #[test]
37483 fn format_number_three_digits_no_comma() {
37484 assert_eq!(format_number(999), "999");
37485 }
37486
37487 #[test]
37488 fn format_number_four_digits_has_comma() {
37489 assert_eq!(format_number(1000), "1,000");
37490 }
37491
37492 #[test]
37493 fn format_number_seven_digits_two_commas() {
37494 assert_eq!(format_number(1_234_567), "1,234,567");
37495 }
37496
37497 #[test]
37498 fn format_number_one_million() {
37499 assert_eq!(format_number(1_000_000), "1,000,000");
37500 }
37501
37502 #[test]
37505 fn badge_text_px_empty_is_zero() {
37506 assert_eq!(badge_text_px(""), 0);
37507 }
37508
37509 #[test]
37510 fn badge_text_px_narrow_chars_smaller_than_normal() {
37511 assert!(
37512 badge_text_px("if") < badge_text_px("ab"),
37513 "'if' must be narrower than 'ab'"
37514 );
37515 }
37516
37517 #[test]
37518 fn badge_text_px_m_is_wider_than_a() {
37519 assert!(
37520 badge_text_px("m") > badge_text_px("a"),
37521 "'m' must be wider than 'a'"
37522 );
37523 }
37524
37525 #[test]
37526 fn render_badge_svg_contains_label_and_value() {
37527 let svg = render_badge_svg("coverage", "95%", "#4c1");
37528 assert!(svg.contains("coverage") && svg.contains("95%"));
37529 }
37530
37531 #[test]
37532 fn render_badge_svg_contains_color() {
37533 let svg = render_badge_svg("sloc", "12K", "#e05d44");
37534 assert!(svg.contains("#e05d44"), "SVG must contain fill color");
37535 }
37536
37537 #[test]
37538 fn render_badge_svg_escapes_ampersand_in_label() {
37539 let svg = render_badge_svg("test&label", "ok", "#4c1");
37540 assert!(svg.contains("&") && !svg.contains("test&label"));
37541 }
37542
37543 #[test]
37546 fn build_pdf_filename_slugifies_title() {
37547 let name = build_pdf_filename("My Project Report", "abc-def-1234");
37548 assert!(
37549 name.starts_with("my_project_report_")
37550 && std::path::Path::new(&name)
37551 .extension()
37552 .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
37553 );
37554 }
37555
37556 #[test]
37557 fn build_pdf_filename_uses_last_run_id_segment() {
37558 let name = build_pdf_filename("project", "uuid-part1-part2-ABCD");
37559 assert!(name.contains("ABCD"), "must use last segment of run_id");
37560 }
37561
37562 #[test]
37563 fn build_pdf_filename_empty_title_uses_report_prefix() {
37564 let name = build_pdf_filename("", "abc-def-9999");
37565 assert!(
37566 name.starts_with("report_")
37567 && std::path::Path::new(&name)
37568 .extension()
37569 .is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
37570 );
37571 }
37572
37573 #[test]
37576 fn swap_chart_js_replaces_inline_block() {
37577 let html = "<html><head><script>// inline source</script></head><body></body></html>";
37578 let result = swap_inline_chart_js_for_static(html.to_string());
37579 assert!(result.contains(r#"src="/static/chart-report.js""#));
37580 assert!(!result.contains("inline source"));
37581 }
37582
37583 #[test]
37584 fn swap_chart_js_no_head_returns_unchanged() {
37585 let html = "<body>no head here</body>";
37586 assert_eq!(swap_inline_chart_js_for_static(html.to_string()), html);
37587 }
37588
37589 #[test]
37590 fn swap_chart_js_no_script_in_head_unchanged() {
37591 let html = "<html><head><style>.x{}</style></head><body></body></html>";
37592 let result = swap_inline_chart_js_for_static(html.to_string());
37593 assert!(!result.contains("chart-report.js"));
37594 }
37595
37596 #[test]
37599 fn patch_html_nonce_replaces_old_nonce() {
37600 let html = r#"<style nonce="old-nonce-123">body{}</style>"#;
37601 let result = patch_html_nonce(html, "new-nonce-456");
37602 assert!(result.contains(r#"nonce="new-nonce-456""#));
37603 assert!(!result.contains("old-nonce-123"));
37604 }
37605
37606 #[test]
37607 fn patch_html_nonce_injects_into_bare_style() {
37608 let html = "<style>body{color:red;}</style>";
37609 let result = patch_html_nonce(html, "fresh-nonce");
37610 assert!(result.contains(r#"<style nonce="fresh-nonce">"#));
37611 }
37612
37613 #[test]
37614 fn patch_html_nonce_injects_into_bare_script() {
37615 let html = "<script>console.log(1);</script>";
37616 let result = patch_html_nonce(html, "abc");
37617 assert!(result.contains(r#"<script nonce="abc">"#));
37618 }
37619
37620 #[test]
37623 fn is_html_report_file_result_html_matches() {
37624 let dir = tempfile::tempdir().unwrap();
37625 let path = dir.path().join("result_20240101.html");
37626 std::fs::write(&path, b"<html></html>").unwrap();
37627 assert!(is_html_report_file(&path));
37628 }
37629
37630 #[test]
37631 fn is_html_report_file_report_html_matches() {
37632 let dir = tempfile::tempdir().unwrap();
37633 let path = dir.path().join("report_abc.html");
37634 std::fs::write(&path, b"<html></html>").unwrap();
37635 assert!(is_html_report_file(&path));
37636 }
37637
37638 #[test]
37639 fn is_html_report_file_index_html_does_not_match() {
37640 let dir = tempfile::tempdir().unwrap();
37641 let path = dir.path().join("index.html");
37642 std::fs::write(&path, b"<html></html>").unwrap();
37643 assert!(!is_html_report_file(&path));
37644 }
37645
37646 #[test]
37647 fn is_html_report_file_nonexistent_returns_false() {
37648 assert!(!is_html_report_file(Path::new(
37649 "/nonexistent/result_xyz.html"
37650 )));
37651 }
37652
37653 #[test]
37654 fn find_html_report_in_dir_finds_result_html() {
37655 let dir = tempfile::tempdir().unwrap();
37656 std::fs::write(dir.path().join("result_xyz.html"), b"<html></html>").unwrap();
37657 assert!(find_html_report_in_dir(dir.path()).is_some());
37658 }
37659
37660 #[test]
37661 fn find_html_report_in_dir_empty_returns_none() {
37662 let dir = tempfile::tempdir().unwrap();
37663 assert!(find_html_report_in_dir(dir.path()).is_none());
37664 }
37665
37666 #[test]
37667 fn find_html_report_in_tree_finds_in_subdir() {
37668 let dir = tempfile::tempdir().unwrap();
37669 let subdir = dir.path().join("run-001");
37670 std::fs::create_dir_all(&subdir).unwrap();
37671 std::fs::write(subdir.join("result_abc.html"), b"<html></html>").unwrap();
37672 assert!(find_html_report_in_tree(dir.path()).is_some());
37673 }
37674
37675 #[test]
37678 fn derive_project_label_with_git_repo_and_ref() {
37679 let label = derive_project_label(
37680 Some("https://github.com/owner/my-repo.git"),
37681 Some("main"),
37682 "/fallback/path",
37683 );
37684 assert!(!label.is_empty(), "label must not be empty");
37685 assert!(
37686 label.contains("my") || label.contains("repo"),
37687 "got: {label}"
37688 );
37689 }
37690
37691 #[test]
37692 fn derive_project_label_fallback_to_path() {
37693 let label = derive_project_label(None, None, "/path/to/myproject");
37694 assert_eq!(label, "myproject");
37695 }
37696
37697 #[test]
37698 fn derive_project_label_empty_git_fields_use_path() {
37699 let label = derive_project_label(Some(""), Some(""), "/home/user/cool-app");
37700 assert_eq!(label, "cool-app");
37701 }
37702
37703 #[test]
37706 fn derive_file_stem_with_commit_appends_sha() {
37707 assert_eq!(
37708 derive_file_stem("myproject", Some("a1b2c3")),
37709 "myproject_a1b2c3"
37710 );
37711 }
37712
37713 #[test]
37714 fn derive_file_stem_without_commit_returns_label() {
37715 assert_eq!(derive_file_stem("myproject", None), "myproject");
37716 }
37717
37718 #[test]
37719 fn derive_file_stem_empty_commit_returns_label() {
37720 assert_eq!(derive_file_stem("myproject", Some("")), "myproject");
37721 }
37722
37723 #[test]
37724 fn derive_run_dir_name_trims_uuid_and_folds_branch() {
37725 let name = derive_run_dir_name(
37726 "myproject",
37727 Some("main"),
37728 "20260827-1234-abcdef0123456789abcdef0123456789",
37729 );
37730 assert_eq!(name, "myproject-main-20260827-1234-abcdef01");
37731 }
37732
37733 #[test]
37734 fn derive_run_dir_name_without_branch_omits_segment() {
37735 let name = derive_run_dir_name(
37736 "myproject",
37737 None,
37738 "20260827-1234-abcdef0123456789abcdef0123456789",
37739 );
37740 assert_eq!(name, "myproject-20260827-1234-abcdef01");
37741 }
37742
37743 #[test]
37744 fn derive_run_dir_name_skips_branch_already_in_label() {
37745 let name = derive_run_dir_name(
37747 "repo-main",
37748 Some("main"),
37749 "20260827-1234-abcdef0123456789abcdef0123456789",
37750 );
37751 assert_eq!(name, "repo-main-20260827-1234-abcdef01");
37752 }
37753
37754 #[test]
37755 fn derive_run_dir_name_passes_through_unexpected_run_id() {
37756 let name = derive_run_dir_name("proj", None, "custom-run");
37758 assert_eq!(name, "proj-custom-run");
37759 }
37760
37761 #[test]
37762 fn derive_run_dir_name_sanitizes_branch_with_slash() {
37763 let name = derive_run_dir_name(
37764 "proj",
37765 Some("feature/new-ui"),
37766 "20260827-1234-abcdef0123456789abcdef0123456789",
37767 );
37768 assert_eq!(name, "proj-new-ui-20260827-1234-abcdef01");
37769 }
37770
37771 #[test]
37774 fn split_patterns_none_is_empty() {
37775 assert!(split_patterns(None).is_empty());
37776 }
37777
37778 #[test]
37779 fn split_patterns_empty_string_is_empty() {
37780 assert!(split_patterns(Some("")).is_empty());
37781 }
37782
37783 #[test]
37784 fn split_patterns_comma_separated() {
37785 assert_eq!(
37786 split_patterns(Some("foo,bar,baz")),
37787 vec!["foo", "bar", "baz"]
37788 );
37789 }
37790
37791 #[test]
37792 fn split_patterns_newline_separated() {
37793 assert_eq!(
37794 split_patterns(Some("foo\nbar\nbaz")),
37795 vec!["foo", "bar", "baz"]
37796 );
37797 }
37798
37799 #[test]
37800 fn split_patterns_trims_whitespace() {
37801 assert_eq!(split_patterns(Some(" foo , bar ")), vec!["foo", "bar"]);
37802 }
37803
37804 #[test]
37807 fn make_git_label_empty_repo_empty_result() {
37808 assert_eq!(make_git_label("", "main"), "");
37809 }
37810
37811 #[test]
37812 fn make_git_label_empty_ref_empty_result() {
37813 assert_eq!(make_git_label("https://github.com/owner/repo", ""), "");
37814 }
37815
37816 #[test]
37817 fn make_git_label_basic_format() {
37818 assert_eq!(
37819 make_git_label("https://github.com/owner/my-repo.git", "main"),
37820 "my-repo_at_main_sloc"
37821 );
37822 }
37823
37824 #[test]
37825 fn make_git_label_slash_in_ref_replaced() {
37826 let label = make_git_label("https://example.com/repo.git", "feature/my-branch");
37827 assert!(
37828 !label.contains('/'),
37829 "slash in ref must be replaced: {label}"
37830 );
37831 }
37832
37833 #[test]
37836 fn format_dir_size_bytes() {
37837 assert_eq!(format_dir_size(500), "500 B");
37838 }
37839
37840 #[test]
37841 fn format_dir_size_kilobytes() {
37842 assert_eq!(format_dir_size(2048), "2 KB");
37843 }
37844
37845 #[test]
37846 fn format_dir_size_megabytes() {
37847 assert!(format_dir_size(5 * 1_048_576).contains("MB"));
37848 }
37849
37850 #[test]
37851 fn format_dir_size_gigabytes() {
37852 assert!(format_dir_size(2 * 1_073_741_824).contains("GB"));
37853 }
37854
37855 #[test]
37856 fn format_dir_size_zero() {
37857 assert_eq!(format_dir_size(0), "0 B");
37858 }
37859
37860 #[test]
37863 fn civil_from_days_epoch() {
37864 assert_eq!(civil_from_days(0), (1970, 1, 1));
37865 }
37866
37867 #[test]
37868 fn civil_from_days_one_year_later() {
37869 assert_eq!(civil_from_days(365), (1971, 1, 1));
37870 }
37871
37872 #[test]
37873 fn civil_from_days_31_days_is_feb_1_1970() {
37874 assert_eq!(civil_from_days(31), (1970, 2, 1));
37875 }
37876
37877 #[test]
37880 fn format_system_time_unix_epoch_formats_correctly() {
37881 assert_eq!(format_system_time(UNIX_EPOCH), "1970-01-01 00:00");
37882 }
37883
37884 #[test]
37885 fn format_system_time_31_days_after_epoch() {
37886 let t = UNIX_EPOCH + Duration::from_hours(744);
37887 assert_eq!(format_system_time(t), "1970-02-01 00:00");
37888 }
37889
37890 #[test]
37891 fn format_system_time_before_epoch_returns_dash() {
37892 if let Some(before) = UNIX_EPOCH.checked_sub(Duration::from_secs(1)) {
37893 assert_eq!(format_system_time(before), "-");
37894 }
37895 }
37896
37897 #[test]
37900 fn detect_language_name_dot_c() {
37901 assert_eq!(detect_language_name("main.c"), Some("C"));
37902 }
37903
37904 #[test]
37905 fn detect_language_name_dot_h() {
37906 assert_eq!(detect_language_name("defs.h"), Some("C"));
37907 }
37908
37909 #[test]
37910 fn detect_language_name_dot_cpp() {
37911 assert_eq!(detect_language_name("algo.cpp"), Some("C++"));
37912 }
37913
37914 #[test]
37915 fn detect_language_name_dot_py() {
37916 assert_eq!(detect_language_name("script.py"), Some("Python"));
37917 }
37918
37919 #[test]
37920 fn detect_language_name_dot_ps1() {
37921 assert_eq!(detect_language_name("Deploy.ps1"), Some("PowerShell"));
37922 }
37923
37924 #[test]
37925 fn detect_language_name_dot_cs() {
37926 assert_eq!(detect_language_name("Program.cs"), Some("C#"));
37927 }
37928
37929 #[test]
37930 fn detect_language_name_dot_sh() {
37931 assert_eq!(detect_language_name("run.sh"), Some("Shell"));
37932 }
37933
37934 #[test]
37935 fn detect_language_name_unknown_txt() {
37936 assert_eq!(detect_language_name("notes.txt"), None);
37937 }
37938
37939 #[test]
37942 fn language_icon_file_c() {
37943 assert_eq!(language_icon_file("C"), Some("c.png"));
37944 }
37945
37946 #[test]
37947 fn language_icon_file_python() {
37948 assert_eq!(language_icon_file("Python"), Some("python.png"));
37949 }
37950
37951 #[test]
37952 fn language_icon_file_dockerfile() {
37953 assert_eq!(language_icon_file("Dockerfile"), Some("docker.png"));
37954 }
37955
37956 #[test]
37957 fn language_icon_file_rust_is_none() {
37958 assert!(language_icon_file("Rust").is_none());
37959 }
37960
37961 #[test]
37962 fn language_icon_file_unknown_is_none() {
37963 assert!(language_icon_file("Fortran").is_none());
37964 }
37965
37966 #[test]
37969 fn language_inline_svg_rust_is_svg() {
37970 let svg = language_inline_svg("Rust").unwrap();
37971 assert!(svg.starts_with("<svg"));
37972 }
37973
37974 #[test]
37975 fn language_inline_svg_typescript_is_some() {
37976 assert!(language_inline_svg("TypeScript").is_some());
37977 }
37978
37979 #[test]
37980 fn language_inline_svg_unknown_is_none() {
37981 assert!(language_inline_svg("Fortran").is_none());
37982 }
37983
37984 #[test]
37987 fn classify_preview_file_c_supported() {
37988 assert!(matches!(
37989 classify_preview_file("main.c"),
37990 PreviewKind::Supported
37991 ));
37992 }
37993
37994 #[test]
37995 fn classify_preview_file_python_supported() {
37996 assert!(matches!(
37997 classify_preview_file("script.py"),
37998 PreviewKind::Supported
37999 ));
38000 }
38001
38002 #[test]
38003 fn classify_preview_file_png_skipped() {
38004 assert!(matches!(
38005 classify_preview_file("image.png"),
38006 PreviewKind::Skipped
38007 ));
38008 }
38009
38010 #[test]
38011 fn classify_preview_file_zip_skipped() {
38012 assert!(matches!(
38013 classify_preview_file("archive.zip"),
38014 PreviewKind::Skipped
38015 ));
38016 }
38017
38018 #[test]
38019 fn classify_preview_file_min_js_skipped() {
38020 assert!(matches!(
38021 classify_preview_file("bundle.min.js"),
38022 PreviewKind::Skipped
38023 ));
38024 }
38025
38026 #[test]
38027 fn classify_preview_file_rs_unsupported() {
38028 assert!(matches!(
38029 classify_preview_file("main.rs"),
38030 PreviewKind::Unsupported
38031 ));
38032 }
38033
38034 #[test]
38037 fn preview_relative_path_strips_root() {
38038 let root = PathBuf::from("/project");
38039 let path = PathBuf::from("/project/src/main.c");
38040 assert_eq!(preview_relative_path(&root, &path), "src/main.c");
38041 }
38042
38043 #[test]
38044 fn preview_relative_path_unrooted_includes_filename() {
38045 let root = PathBuf::from("/other");
38046 let path = PathBuf::from("/project/src/main.c");
38047 let result = preview_relative_path(&root, &path);
38048 assert!(result.contains("main.c"));
38049 }
38050
38051 #[test]
38052 fn preview_relative_path_uses_forward_slashes() {
38053 let root = PathBuf::from("/project");
38054 let path = PathBuf::from("/project/a/b/c.py");
38055 assert!(!preview_relative_path(&root, &path).contains('\\'));
38056 }
38057
38058 #[test]
38061 fn wildcard_match_exact_equal() {
38062 assert!(wildcard_match("foo", "foo"));
38063 }
38064
38065 #[test]
38066 fn wildcard_match_exact_mismatch() {
38067 assert!(!wildcard_match("foo", "bar"));
38068 }
38069
38070 #[test]
38071 fn wildcard_match_star_suffix() {
38072 assert!(wildcard_match("*.rs", "main.rs"));
38073 }
38074
38075 #[test]
38076 fn wildcard_match_star_middle_requires_suffix() {
38077 assert!(!wildcard_match("a*b", "ac"));
38078 }
38079
38080 #[test]
38081 fn wildcard_match_question_mark_single_char() {
38082 assert!(wildcard_match("f?o", "foo"));
38083 }
38084
38085 #[test]
38086 fn wildcard_match_double_star_nested() {
38087 assert!(wildcard_match("src/**", "src/a/b/c.rs"));
38088 }
38089
38090 #[test]
38091 fn wildcard_match_star_directory_entry() {
38092 assert!(wildcard_match("vendor/*", "vendor/crate"));
38093 }
38094
38095 #[test]
38096 fn wildcard_match_no_cross_prefix() {
38097 assert!(!wildcard_match("src/*.rs", "tests/foo.rs"));
38098 }
38099
38100 #[test]
38103 fn should_skip_empty_relative_is_false() {
38104 assert!(!should_skip_preview_directory("", &["vendor".to_string()]));
38105 }
38106
38107 #[test]
38108 fn should_skip_matching_pattern() {
38109 assert!(should_skip_preview_directory(
38110 "vendor",
38111 &["vendor".to_string()]
38112 ));
38113 }
38114
38115 #[test]
38116 fn should_skip_non_matching() {
38117 assert!(!should_skip_preview_directory(
38118 "src",
38119 &["vendor".to_string()]
38120 ));
38121 }
38122
38123 #[test]
38124 fn should_skip_wildcard_prefix() {
38125 assert!(should_skip_preview_directory(
38126 "target/debug",
38127 &["target*".to_string()]
38128 ));
38129 }
38130
38131 #[test]
38134 fn should_include_empty_relative_always_true() {
38135 assert!(should_include_preview_file("", &[], &[]));
38136 }
38137
38138 #[test]
38139 fn should_include_no_patterns_includes_all() {
38140 assert!(should_include_preview_file("src/main.c", &[], &[]));
38141 }
38142
38143 #[test]
38144 fn should_include_excluded_by_pattern() {
38145 assert!(!should_include_preview_file(
38146 "vendor/lib.c",
38147 &[],
38148 &["vendor/*".to_string()]
38149 ));
38150 }
38151
38152 #[test]
38153 fn should_include_include_pattern_filters() {
38154 assert!(!should_include_preview_file(
38155 "tests/test_foo.c",
38156 &["src/*".to_string()],
38157 &[]
38158 ));
38159 }
38160
38161 #[test]
38164 fn escape_html_ampersand() {
38165 assert_eq!(escape_html("a&b"), "a&b");
38166 }
38167
38168 #[test]
38169 fn escape_html_angle_brackets() {
38170 assert_eq!(escape_html("<br>"), "<br>");
38171 }
38172
38173 #[test]
38174 fn escape_html_double_quote() {
38175 assert_eq!(escape_html(r#"say "hello""#), "say "hello"");
38176 }
38177
38178 #[test]
38179 fn escape_html_single_quote() {
38180 assert_eq!(escape_html("it's"), "it's");
38181 }
38182
38183 #[test]
38184 fn escape_html_plain_text_unchanged() {
38185 assert_eq!(escape_html("hello world"), "hello world");
38186 }
38187
38188 fn make_mixed_scan_comparison() -> sloc_core::ScanComparison {
38191 sloc_core::ScanComparison {
38192 summary: sloc_core::SummaryDelta {
38193 baseline_run_id: "base".to_string(),
38194 current_run_id: "curr".to_string(),
38195 baseline_timestamp: chrono::Utc::now(),
38196 current_timestamp: chrono::Utc::now(),
38197 baseline_files: 4,
38198 current_files: 4,
38199 files_analyzed_delta: 0,
38200 baseline_code: 330,
38201 current_code: 400,
38202 code_lines_delta: 70,
38203 baseline_comments: 0,
38204 current_comments: 0,
38205 comment_lines_delta: 0,
38206 blank_lines_delta: 0,
38207 total_lines_delta: 70,
38208 coverage_lines_hit_delta: None,
38209 coverage_line_pct_delta: None,
38210 baseline_coverage_line_pct: None,
38211 current_coverage_line_pct: None,
38212 },
38213 file_deltas: vec![
38214 sloc_core::FileDelta {
38215 relative_path: "added.rs".to_string(),
38216 language: Some("Rust".to_string()),
38217 status: FileChangeStatus::Added,
38218 baseline_code: 0,
38219 current_code: 100,
38220 code_delta: 100,
38221 baseline_comment: 0,
38222 current_comment: 0,
38223 comment_delta: 0,
38224 baseline_blank: 0,
38225 current_blank: 0,
38226 blank_delta: 0,
38227 total_delta: 100,
38228 },
38229 sloc_core::FileDelta {
38230 relative_path: "removed.rs".to_string(),
38231 language: Some("Rust".to_string()),
38232 status: FileChangeStatus::Removed,
38233 baseline_code: 50,
38234 current_code: 0,
38235 code_delta: -50,
38236 baseline_comment: 0,
38237 current_comment: 0,
38238 comment_delta: 0,
38239 baseline_blank: 0,
38240 current_blank: 0,
38241 blank_delta: 0,
38242 total_delta: -50,
38243 },
38244 sloc_core::FileDelta {
38245 relative_path: "modified.rs".to_string(),
38246 language: Some("Rust".to_string()),
38247 status: FileChangeStatus::Modified,
38248 baseline_code: 80,
38249 current_code: 100,
38250 code_delta: 20,
38251 baseline_comment: 0,
38252 current_comment: 0,
38253 comment_delta: 0,
38254 baseline_blank: 0,
38255 current_blank: 0,
38256 blank_delta: 0,
38257 total_delta: 20,
38258 },
38259 sloc_core::FileDelta {
38260 relative_path: "unchanged.rs".to_string(),
38261 language: Some("Rust".to_string()),
38262 status: FileChangeStatus::Unchanged,
38263 baseline_code: 200,
38264 current_code: 200,
38265 code_delta: 0,
38266 baseline_comment: 0,
38267 current_comment: 0,
38268 comment_delta: 0,
38269 baseline_blank: 0,
38270 current_blank: 0,
38271 blank_delta: 0,
38272 total_delta: 0,
38273 },
38274 ],
38275 files_added: 1,
38276 files_removed: 1,
38277 files_modified: 1,
38278 files_unchanged: 1,
38279 files_total: 4,
38280 }
38281 }
38282
38283 #[test]
38284 fn sum_added_counts_added_and_positive_modified() {
38285 let cmp = make_mixed_scan_comparison();
38286 assert_eq!(sum_added_code_lines(&cmp), 120);
38287 }
38288
38289 #[test]
38290 fn sum_removed_counts_removed_baseline() {
38291 let cmp = make_mixed_scan_comparison();
38292 assert_eq!(sum_removed_code_lines(&cmp), 50);
38293 }
38294
38295 #[test]
38296 fn sum_unmodified_counts_unchanged_files() {
38297 let cmp = make_mixed_scan_comparison();
38298 assert_eq!(sum_unmodified_code_lines(&cmp), 200);
38299 }
38300
38301 #[test]
38304 fn detect_coverage_tool_rust_project() {
38305 let dir = tempfile::tempdir().unwrap();
38306 std::fs::write(dir.path().join("Cargo.toml"), b"[package]").unwrap();
38307 let (tool, cmd) = detect_coverage_tool(dir.path());
38308 assert_eq!(tool, Some("cargo-llvm-cov"));
38309 assert!(cmd.is_some());
38310 }
38311
38312 #[test]
38313 fn detect_coverage_tool_java_gradle() {
38314 let dir = tempfile::tempdir().unwrap();
38315 std::fs::write(dir.path().join("build.gradle"), b"apply plugin: 'java'").unwrap();
38316 let (tool, _) = detect_coverage_tool(dir.path());
38317 assert_eq!(tool, Some("jacoco"));
38318 }
38319
38320 #[test]
38321 fn detect_coverage_tool_python_pyproject() {
38322 let dir = tempfile::tempdir().unwrap();
38323 std::fs::write(dir.path().join("pyproject.toml"), b"[tool.poetry]").unwrap();
38324 let (tool, _) = detect_coverage_tool(dir.path());
38325 assert_eq!(tool, Some("pytest-cov"));
38326 }
38327
38328 #[test]
38329 fn detect_coverage_tool_unknown_project() {
38330 let dir = tempfile::tempdir().unwrap();
38331 let (tool, cmd) = detect_coverage_tool(dir.path());
38332 assert!(tool.is_none() && cmd.is_none());
38333 }
38334
38335 #[test]
38338 fn sanitize_path_str_unc_drive_stripped() {
38339 assert_eq!(sanitize_path_str("//?/C:/Users/user"), "C:/Users/user");
38340 }
38341
38342 #[test]
38343 fn sanitize_path_str_unc_network_stripped() {
38344 assert_eq!(sanitize_path_str("//?/UNC/server/share"), "//server/share");
38345 }
38346
38347 #[test]
38348 fn sanitize_path_str_plain_path_unchanged() {
38349 assert_eq!(
38350 sanitize_path_str("/home/user/project"),
38351 "/home/user/project"
38352 );
38353 }
38354
38355 #[test]
38356 fn display_path_plain_linux_unchanged() {
38357 assert_eq!(
38358 display_path(Path::new("/home/user/project")),
38359 "/home/user/project"
38360 );
38361 }
38362
38363 #[test]
38364 fn display_path_unc_drive_stripped() {
38365 let result = display_path(Path::new(r"\\?\C:\Users\user"));
38366 assert_eq!(result, r"C:\Users\user");
38367 }
38368
38369 #[test]
38370 fn display_path_unc_network_stripped() {
38371 let result = display_path(Path::new(r"\\?\UNC\server\share"));
38372 assert_eq!(result, r"\\server\share");
38373 }
38374}
38375
38376#[cfg(test)]
38377mod coverage_boost_unit_tests {
38378 use super::*;
38379 use std::path::{Path, PathBuf};
38380
38381 #[tokio::test]
38385 async fn runtime_security_config_scenarios() {
38386 unsafe { std::env::remove_var("SLOC_API_KEYS") };
38388 unsafe { std::env::remove_var("SLOC_API_KEY") };
38390 unsafe { std::env::remove_var("SLOC_TLS_CERT") };
38392 unsafe { std::env::remove_var("SLOC_TLS_KEY") };
38394 unsafe { std::env::remove_var("SLOC_TRUST_PROXY") };
38396 unsafe { std::env::remove_var("SLOC_TRUSTED_PROXY_IPS") };
38398 let cfg = load_runtime_security_config(false);
38399 assert!(cfg.api_keys.is_empty());
38400 assert!(!cfg.tls_enabled);
38401 assert!(!cfg.trust_proxy);
38402
38403 unsafe { std::env::set_var("SLOC_API_KEYS", "alpha, beta ,") };
38405 unsafe { std::env::set_var("SLOC_TRUST_PROXY", "1") };
38407 unsafe { std::env::set_var("SLOC_TRUSTED_PROXY_IPS", "127.0.0.1, 10.0.0.2") };
38409 unsafe { std::env::set_var("SLOC_RATE_LIMIT", "250") };
38411 unsafe { std::env::set_var("SLOC_AUTH_LOCKOUT_FAILS", "5") };
38413 unsafe { std::env::set_var("SLOC_AUTH_LOCKOUT_SECS", "60") };
38415 let cfg = load_runtime_security_config(true);
38416 assert_eq!(cfg.api_keys.len(), 2, "two non-empty keys parsed");
38417 assert!(cfg.trust_proxy);
38418 assert_eq!(cfg.trusted_proxy_ips.len(), 2);
38419 unsafe { std::env::remove_var("SLOC_API_KEYS") };
38421 unsafe { std::env::remove_var("SLOC_TRUST_PROXY") };
38423 unsafe { std::env::remove_var("SLOC_TRUSTED_PROXY_IPS") };
38425 unsafe { std::env::remove_var("SLOC_RATE_LIMIT") };
38427 unsafe { std::env::remove_var("SLOC_AUTH_LOCKOUT_FAILS") };
38429 unsafe { std::env::remove_var("SLOC_AUTH_LOCKOUT_SECS") };
38431 }
38432
38433 #[test]
38434 fn cors_layer_builds_both_modes() {
38435 let _ = build_cors_layer(true);
38436 let _ = build_cors_layer(false);
38437 }
38438
38439 #[test]
38440 fn primary_lan_ip_callable() {
38441 let _ = primary_lan_ip();
38443 }
38444
38445 #[test]
38446 fn safe_redirect_allows_relative_rejects_absolute() {
38447 assert_eq!(safe_redirect("/view-reports"), "/view-reports");
38448 assert_eq!(safe_redirect("https://evil.example/x"), "/");
38449 assert_eq!(safe_redirect("javascript:alert(1)"), "/");
38450 assert_eq!(default_redirect(), "/view-reports");
38451 }
38452
38453 #[test]
38454 fn tarball_size_caps_env_override() {
38455 unsafe { std::env::set_var("SLOC_MAX_TARBALL_MB", "1") };
38457 unsafe { std::env::set_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB", "2") };
38459 let (c, d) = parse_tarball_size_caps();
38460 assert_eq!(c, 1024 * 1024);
38461 assert_eq!(d, 2 * 1024 * 1024);
38462 unsafe { std::env::remove_var("SLOC_MAX_TARBALL_MB") };
38464 unsafe { std::env::remove_var("SLOC_MAX_TARBALL_DECOMPRESSED_MB") };
38466 let (c2, _) = parse_tarball_size_caps();
38467 assert_eq!(c2, 2048 * 1024 * 1024, "default 2048 MB");
38468 }
38469
38470 #[test]
38471 fn upload_path_helpers() {
38472 let base = upload_base_dir();
38473 let staged = upload_staging_path("abc123");
38474 assert!(staged.starts_with(&base));
38475 assert!(
38476 is_upload_tmp_path(&staged),
38477 "staging path is an upload tmp path"
38478 );
38479 assert!(!is_upload_tmp_path(Path::new("/etc/passwd")));
38480 }
38481
38482 #[test]
38483 fn git_clones_dir_env_override() {
38484 unsafe { std::env::remove_var("SLOC_GIT_CLONES_DIR") };
38486 let def = resolve_git_clones_dir(Path::new("/out"));
38487 assert_eq!(def, PathBuf::from("/out").join("git-clones"));
38488 unsafe { std::env::set_var("SLOC_GIT_CLONES_DIR", "/custom/clones") };
38490 assert_eq!(
38491 resolve_git_clones_dir(Path::new("/out")),
38492 PathBuf::from("/custom/clones")
38493 );
38494 unsafe { std::env::remove_var("SLOC_GIT_CLONES_DIR") };
38496 }
38497
38498 #[test]
38499 fn html_report_file_detection() {
38500 let dir = std::env::temp_dir().join("sloc_html_detect");
38501 let _ = std::fs::create_dir_all(&dir);
38502 let good = dir.join("report_x.html");
38503 std::fs::write(&good, "<html></html>").unwrap();
38504 let bad = dir.join("notes.txt");
38505 std::fs::write(&bad, "x").unwrap();
38506 assert!(is_html_report_file(&good));
38507 assert!(!is_html_report_file(&bad));
38508 assert!(find_html_report_in_dir(&dir).is_some());
38509 let _ = std::fs::remove_dir_all(&dir);
38510 }
38511
38512 #[test]
38513 fn multi_delta_class_and_format() {
38514 assert_eq!(multi_delta_class(5), "pos");
38515 assert_eq!(multi_delta_class(-5), "neg");
38516 assert_eq!(multi_delta_class(0), "zero");
38517 assert_eq!(multi_fmt_delta(3), "+3");
38518 assert_eq!(multi_fmt_delta(-3), "-3");
38519 assert_eq!(multi_fmt_delta(0), "0");
38520 }
38521
38522 #[test]
38523 fn git_clone_dest_sanitizes() {
38524 let dest = git_clone_dest("https://github.com/org/repo.git", Path::new("/clones"));
38525 assert!(dest.starts_with("/clones"));
38526 let name = dest.file_name().unwrap().to_str().unwrap();
38527 assert!(
38528 name.chars()
38529 .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.'))
38530 );
38531 }
38532}
38533
38534#[cfg(test)]
38535mod tests_private {
38536 use super::*;
38537 use std::io::Read;
38538
38539 fn test_nonce() -> String {
38543 uuid::Uuid::new_v4().to_string().replace('-', "")
38544 }
38545
38546 #[test]
38549 fn local_mode_never_refuses_start() {
38550 assert!(!refuse_unauthenticated_server(false, false));
38552 assert!(!refuse_unauthenticated_server(false, true));
38553 }
38554
38555 #[test]
38558 fn loopback_binds_are_not_network_facing() {
38559 assert!(!bind_is_network_facing("127.0.0.1:4317"));
38560 assert!(!bind_is_network_facing("127.0.0.1"));
38561 assert!(!bind_is_network_facing("[::1]:4317"));
38562 assert!(!bind_is_network_facing("localhost:4317"));
38563 assert!(!bind_is_network_facing("LOCALHOST"));
38564 assert!(!bind_is_network_facing("127.5.5.5:8080")); }
38566
38567 #[test]
38568 fn wildcard_and_lan_binds_are_network_facing() {
38569 assert!(bind_is_network_facing("0.0.0.0:4317"));
38570 assert!(bind_is_network_facing("[::]:4317"));
38571 assert!(bind_is_network_facing("192.168.1.50:4317"));
38572 assert!(bind_is_network_facing("10.0.0.1:80"));
38573 assert!(bind_is_network_facing("sloc.corp.local:4317"));
38575 assert!(bind_is_network_facing("not a bind address"));
38576 }
38577
38578 #[test]
38581 fn combine_disk_caps_takes_the_smaller_and_ignores_zero() {
38582 assert_eq!(combine_disk_caps_mb(Some(100), Some(200)), Some(100));
38583 assert_eq!(combine_disk_caps_mb(Some(200), Some(100)), Some(100));
38584 assert_eq!(combine_disk_caps_mb(Some(100), None), Some(100));
38585 assert_eq!(combine_disk_caps_mb(None, Some(100)), Some(100));
38586 assert_eq!(combine_disk_caps_mb(None, None), None);
38587 assert_eq!(combine_disk_caps_mb(Some(0), Some(50)), Some(50));
38589 assert_eq!(combine_disk_caps_mb(Some(0), Some(0)), None);
38590 }
38591
38592 #[test]
38593 fn size_cap_keeps_newest_runs_that_fit() {
38594 let sized = vec![
38596 ("a".to_string(), 30),
38597 ("b".to_string(), 30),
38598 ("c".to_string(), 30),
38599 ];
38600 let del = select_runs_over_size_cap(&sized, 70);
38601 assert_eq!(del.len(), 1);
38602 assert!(del.contains("c"));
38603 assert!(!del.contains("a"));
38604 }
38605
38606 #[test]
38607 fn size_cap_under_budget_deletes_nothing() {
38608 let sized = vec![("a".to_string(), 10), ("b".to_string(), 10)];
38609 assert!(select_runs_over_size_cap(&sized, 1_000).is_empty());
38610 }
38611
38612 #[test]
38613 fn size_cap_zero_deletes_everything() {
38614 let sized = vec![("a".to_string(), 1), ("b".to_string(), 1)];
38615 assert_eq!(select_runs_over_size_cap(&sized, 0).len(), 2);
38616 }
38617
38618 #[test]
38621 fn host_of_url_extracts_authority() {
38622 assert_eq!(
38623 host_of_url("https://sloc.corp.local"),
38624 Some("sloc.corp.local".into())
38625 );
38626 assert_eq!(
38627 host_of_url("http://sloc.corp.local:4317/path?q=1"),
38628 Some("sloc.corp.local:4317".into())
38629 );
38630 assert_eq!(
38631 host_of_url("user@host.example:8080"),
38632 Some("host.example:8080".into())
38633 );
38634 assert_eq!(host_of_url("HOST.Example"), Some("host.example".into()));
38635 assert_eq!(host_of_url(""), None);
38636 }
38637
38638 #[test]
38639 fn parse_allowed_hosts_splits_commas_and_whitespace() {
38640 let got = parse_allowed_hosts(" sloc.corp.local, 10.0.0.5:4317\n host2 ");
38641 assert_eq!(got, vec!["sloc.corp.local", "10.0.0.5:4317", "host2"]);
38642 assert!(parse_allowed_hosts(" ").is_empty());
38643 }
38644
38645 #[test]
38646 fn host_is_allowed_matches_with_and_without_port() {
38647 let allowed = parse_allowed_hosts("sloc.corp.local, 10.0.0.5:4317");
38648 assert!(host_is_allowed("sloc.corp.local", &allowed));
38650 assert!(host_is_allowed("sloc.corp.local:4317", &allowed));
38651 assert!(host_is_allowed("SLOC.CORP.LOCAL:9999", &allowed));
38652 assert!(host_is_allowed("10.0.0.5:4317", &allowed));
38654 assert!(!host_is_allowed("10.0.0.5:9999", &allowed));
38655 assert!(!host_is_allowed("evil.example", &allowed));
38657 assert!(!host_is_allowed("", &allowed));
38658 }
38659
38660 #[test]
38661 fn host_check_exempts_probes_and_webhooks() {
38662 assert!(host_check_exempt("/healthz"));
38663 assert!(host_check_exempt("/readyz"));
38664 assert!(host_check_exempt("/metrics"));
38665 assert!(host_check_exempt("/webhooks/github"));
38666 assert!(!host_check_exempt("/"));
38667 assert!(!host_check_exempt("/analyze"));
38668 }
38669
38670 #[test]
38671 fn split_host_port_handles_ipv6_and_names() {
38672 assert_eq!(split_host_port("host"), ("host", None));
38673 assert_eq!(split_host_port("host:80"), ("host", Some("80")));
38674 assert_eq!(split_host_port("[::1]:80"), ("::1", Some("80")));
38675 assert_eq!(split_host_port("[2001:db8::1]"), ("2001:db8::1", None));
38676 }
38677
38678 #[test]
38681 fn code_ownership_empty_state_renders_guidance() {
38682 let html = render_code_ownership_html(&test_nonce(), None, "myrepo", &[], &[], None, "");
38683 assert!(html.contains("No ownership data"));
38684 assert!(html.contains("oxide-sloc analyze"));
38685 assert!(html.contains("myrepo"));
38686 assert!(html.contains("site-footer"));
38687 assert!(html.contains("id=\"theme-toggle\""));
38688 assert!(html.contains("Code Ownership"));
38689 }
38690
38691 #[test]
38692 fn code_ownership_project_selector_renders_and_marks_selection() {
38693 assert_eq!(build_project_selector(&[], None), "");
38695
38696 let projects = vec!["alpha".to_string(), "beta".to_string()];
38699 let all = build_project_selector(&projects, None);
38700 assert!(all.contains("own-project-select"));
38701 assert!(all.contains(r#"<option value="" selected>All projects (latest scan)</option>"#));
38702 assert!(all.contains(r#"<option value="alpha">alpha</option>"#));
38703 assert!(all.contains(r#"<option value="beta">beta</option>"#));
38704
38705 let beta = build_project_selector(&projects, Some("beta"));
38707 assert!(beta.contains(r#"<option value="beta" selected>beta</option>"#));
38708 assert!(beta.contains(r#"<option value="">All projects (latest scan)</option>"#));
38709
38710 let page = render_code_ownership_html(
38712 &test_nonce(),
38713 None,
38714 "myrepo",
38715 &[],
38716 &projects,
38717 Some("alpha"),
38718 "",
38719 );
38720 assert!(page.contains("own-project-select"));
38721 assert!(page.contains(r#"<option value="alpha" selected>alpha</option>"#));
38722 }
38723
38724 #[test]
38725 fn parse_remote_host_slug_handles_https_ssh_and_scp() {
38726 assert_eq!(
38727 parse_remote_host_slug("https://github.com/oxide-sloc/oxide-sloc.git"),
38728 Some(("github.com".into(), "oxide-sloc/oxide-sloc".into()))
38729 );
38730 assert_eq!(
38731 parse_remote_host_slug("git@github.com:owner/repo.git"),
38732 Some(("github.com".into(), "owner/repo".into()))
38733 );
38734 assert_eq!(
38735 parse_remote_host_slug("ssh://git@gitlab.com/group/sub/repo"),
38736 Some(("gitlab.com".into(), "group/sub/repo".into()))
38737 );
38738 assert_eq!(
38740 parse_remote_host_slug("https://user:tok@bitbucket.org/team/repo"),
38741 Some(("bitbucket.org".into(), "team/repo".into()))
38742 );
38743 assert_eq!(parse_remote_host_slug("not-a-url"), None);
38744 assert_eq!(parse_remote_host_slug("https://github.com/"), None);
38745 }
38746
38747 #[test]
38748 fn author_profile_url_prefers_github_noreply_then_falls_back_to_host_filter() {
38749 assert_eq!(
38751 author_profile_url(None, "Nima", "12345+nimzshafie@users.noreply.github.com"),
38752 Some("https://github.com/nimzshafie".into())
38753 );
38754 assert_eq!(
38755 author_profile_url(None, "Nima", "nimzshafie@users.noreply.github.com"),
38756 Some("https://github.com/nimzshafie".into())
38757 );
38758 assert_eq!(
38760 author_profile_url(
38761 Some("https://github.com/owner/repo.git"),
38762 "Nima Shafie",
38763 "nima@corp.com"
38764 ),
38765 Some("https://github.com/owner/repo/commits?author=nima%40corp.com".into())
38766 );
38767 assert_eq!(
38769 author_profile_url(Some("git@gitlab.com:grp/repo.git"), "A B", "a@b.com"),
38770 Some("https://gitlab.com/grp/repo/-/commits?author=A%20B".into())
38771 );
38772 assert_eq!(
38773 author_profile_url(Some("https://bitbucket.org/t/r"), "A B", "a@b.com"),
38774 Some("https://bitbucket.org/t/r/commits/?author=a%40b.com".into())
38775 );
38776 assert_eq!(
38778 author_profile_url(Some("https://evil.example/x/y"), "A", "a@b.com"),
38779 None
38780 );
38781 assert_eq!(author_profile_url(None, "A", "a@b.com"), None);
38782 }
38783
38784 #[test]
38785 fn code_ownership_populated_renders_authors_and_bus_factor() {
38786 let json = serde_json::json!({
38787 "tool": {"name":"oxide-sloc","version":"0.0.0","run_id":"t","timestamp_utc":"2026-01-01T00:00:00Z"},
38788 "environment": {"operating_system":"x","architecture":"x86_64","runtime_mode":"cli","initiator_username":"u","initiator_hostname":"h"},
38789 "effective_configuration": {},
38790 "input_roots": ["/tmp/myrepo"],
38791 "summary_totals": {"files_considered":1,"files_analyzed":1,"files_skipped":0,"total_physical_lines":260,"code_lines":200,"comment_lines":40,"blank_lines":20,"mixed_lines_separate":0},
38792 "totals_by_language": [],
38793 "per_file_records": [],
38794 "skipped_file_records": [],
38795 "warnings": [],
38796 "authors": [
38797 {"id":0,"canonical_name":"Nima Shafie","canonical_email":"nimzshafie@gmail.com",
38798 "aliases":[{"name":"Nima Shafie","email":"nimzshafie@gmail.com"},{"name":"nshafie","email":"nimzshafie@gmail.com"}],
38799 "counts":{"code_lines":150,"comment_lines":30,"blank_lines":15,"total_lines":195}},
38800 {"id":1,"canonical_name":"Other Dev","canonical_email":"other@example.com",
38801 "aliases":[{"name":"Other Dev","email":"other@example.com"}],
38802 "counts":{"code_lines":50,"comment_lines":10,"blank_lines":5,"total_lines":65}}
38803 ]
38804 });
38805 let run: AnalysisRun = serde_json::from_value(json).expect("run deserializes");
38806 let html =
38807 render_code_ownership_html(&test_nonce(), Some(&run), "myrepo", &[], &[], None, "");
38808 assert!(html.contains("Nima Shafie"));
38809 assert!(html.contains("Other Dev"));
38810 assert!(html.contains("own-data"));
38811 assert!(html.contains("Contributors"));
38812 assert!(html.contains("own-bar-fill"));
38813 assert!(!html.contains("No ownership data"));
38814 assert!(html.contains(">1</div><div class=\"stat-chip-label\">Bus Factor"));
38816 assert!(html.contains("Combine contributors"));
38818 assert!(html.contains("/api/ownership/merge"));
38819 assert!(html.contains("name=\"email\" value=\"nimzshafie@gmail.com\""));
38820 }
38821
38822 #[test]
38823 fn code_ownership_shows_active_merge_group() {
38824 let group = AuthorMergeGroup {
38825 canonical_name: "Nima Shafie".into(),
38826 canonical_email: "nima@corp.com".into(),
38827 members: vec!["nima@corp.com".into(), "nima@personal.com".into()],
38828 };
38829 let json = serde_json::json!({
38830 "tool": {"name":"oxide-sloc","version":"0.0.0","run_id":"t","timestamp_utc":"2026-01-01T00:00:00Z"},
38831 "environment": {"operating_system":"x","architecture":"x86_64","runtime_mode":"cli","initiator_username":"u","initiator_hostname":"h"},
38832 "effective_configuration": {},
38833 "input_roots": ["/tmp/myrepo"],
38834 "summary_totals": {"files_considered":1,"files_analyzed":1,"files_skipped":0,"total_physical_lines":260,"code_lines":200,"comment_lines":40,"blank_lines":20,"mixed_lines_separate":0},
38835 "totals_by_language": [],
38836 "per_file_records": [],
38837 "skipped_file_records": [],
38838 "warnings": [],
38839 "authors": [
38840 {"id":0,"canonical_name":"Nima Shafie","canonical_email":"nima@corp.com","aliases":[],
38841 "counts":{"code_lines":150,"comment_lines":30,"blank_lines":15,"total_lines":195}}
38842 ]
38843 });
38844 let run: AnalysisRun = serde_json::from_value(json).expect("run deserializes");
38845 let html = render_code_ownership_html(
38846 &test_nonce(),
38847 Some(&run),
38848 "myrepo",
38849 std::slice::from_ref(&group),
38850 &[],
38851 None,
38852 "",
38853 );
38854 assert!(html.contains("Active merges"));
38855 assert!(html.contains("/api/ownership/unmerge"));
38856 assert!(html.contains("Unmerge"));
38857 }
38858
38859 #[test]
38860 fn code_ownership_language_table_and_files_owned() {
38861 use sloc_core::{AuthorLineCounts, FileOwnership, FileRecord, FileStatus};
38862 use sloc_languages::Language;
38863
38864 let own = |author_id: u32, code: u64| FileOwnership {
38865 author_id,
38866 counts: AuthorLineCounts {
38867 code_lines: code,
38868 comment_lines: 0,
38869 blank_lines: 0,
38870 total_lines: code,
38871 },
38872 };
38873 let rec = |path: &str, lang: Language, owners: Vec<FileOwnership>| FileRecord {
38874 path: path.into(),
38875 relative_path: path.into(),
38876 language: Some(lang),
38877 size_bytes: 100,
38878 detected_encoding: None,
38879 raw_line_categories: Default::default(),
38880 effective_counts: Default::default(),
38881 status: FileStatus::AnalyzedExact,
38882 warnings: vec![],
38883 generated: false,
38884 minified: false,
38885 vendor: false,
38886 parse_mode: None,
38887 submodule: None,
38888 coverage: None,
38889 style_analysis: None,
38890 cyclomatic_complexity: None,
38891 lsloc: None,
38892 commit_count: None,
38893 last_commit_date: None,
38894 ownership: Some(owners),
38895 content_hash: 0,
38896 };
38897
38898 let json = serde_json::json!({
38899 "tool": {"name":"oxide-sloc","version":"0.0.0","run_id":"t","timestamp_utc":"2026-01-01T00:00:00Z"},
38900 "environment": {"operating_system":"x","architecture":"x86_64","runtime_mode":"cli","initiator_username":"u","initiator_hostname":"h"},
38901 "effective_configuration": {},
38902 "input_roots": ["/tmp/myrepo"],
38903 "summary_totals": {"files_considered":2,"files_analyzed":2,"files_skipped":0,"total_physical_lines":235,"code_lines":235,"comment_lines":0,"blank_lines":0,"mixed_lines_separate":0},
38904 "totals_by_language": [],
38905 "per_file_records": [],
38906 "skipped_file_records": [],
38907 "warnings": [],
38908 "authors": [
38909 {"id":0,"canonical_name":"Nima Shafie","canonical_email":"nimzshafie@gmail.com","aliases":[],
38910 "counts":{"code_lines":125,"comment_lines":0,"blank_lines":0,"total_lines":125}},
38911 {"id":1,"canonical_name":"Other Dev","canonical_email":"other@example.com","aliases":[],
38912 "counts":{"code_lines":110,"comment_lines":0,"blank_lines":0,"total_lines":110}}
38913 ]
38914 });
38915 let mut run: AnalysisRun = serde_json::from_value(json).expect("run deserializes");
38916 run.per_file_records = vec![
38917 rec("a.rs", Language::Rust, vec![own(0, 120), own(1, 30)]),
38919 rec("b.py", Language::Python, vec![own(1, 80), own(0, 5)]),
38920 ];
38921
38922 let html =
38923 render_code_ownership_html(&test_nonce(), Some(&run), "myrepo", &[], &[], None, "");
38924 assert!(html.contains("Ownership by language"));
38926 assert!(html.contains("Rust"));
38927 assert!(html.contains("Python"));
38928 assert!(html.contains("Nima Shafie"));
38930 assert!(html.contains("Other Dev"));
38931 }
38932
38933 #[test]
38934 fn language_badge_maps_known_and_falls_back() {
38935 assert_eq!(language_badge_meta("Rust"), Some(("Rs", "#DEA584")));
38937 assert_eq!(language_badge_meta("C++"), Some(("C++", "#F34B7D")));
38938 assert_eq!(language_badge_meta("C#"), Some(("C#", "#178600")));
38939 assert_eq!(language_badge_meta("C"), Some(("C", "#555555")));
38940 assert_eq!(language_badge_meta("TypeScript"), Some(("Ts", "#3178C6")));
38941 assert_eq!(language_badge_meta("Whitespace"), None);
38943
38944 let svg = language_badge("Rust");
38946 assert!(svg.contains("lang-badge"));
38947 assert!(svg.contains("<svg"));
38948 assert!(svg.contains(">Rs<"));
38949 assert!(svg.contains("#DEA584"));
38950 assert!(!svg.contains("style="));
38951 assert!(language_badge("Whitespace").contains(">WH<"));
38953 }
38954
38955 #[test]
38956 fn ownership_dev_test_split_classifies_by_file() {
38957 use sloc_core::{AuthorLineCounts, FileOwnership, FileRecord, FileStatus};
38958 use sloc_languages::Language;
38959
38960 let own = |author_id: u32, code: u64| FileOwnership {
38961 author_id,
38962 counts: AuthorLineCounts {
38963 code_lines: code,
38964 comment_lines: 0,
38965 blank_lines: 0,
38966 total_lines: code,
38967 },
38968 };
38969 let rec = |path: &str, owners: Vec<FileOwnership>| FileRecord {
38970 path: path.into(),
38971 relative_path: path.into(),
38972 language: Some(Language::Rust),
38973 size_bytes: 100,
38974 detected_encoding: None,
38975 raw_line_categories: Default::default(),
38976 effective_counts: Default::default(),
38977 status: FileStatus::AnalyzedExact,
38978 warnings: vec![],
38979 generated: false,
38980 minified: false,
38981 vendor: false,
38982 parse_mode: None,
38983 submodule: None,
38984 coverage: None,
38985 style_analysis: None,
38986 cyclomatic_complexity: None,
38987 lsloc: None,
38988 commit_count: None,
38989 last_commit_date: None,
38990 ownership: Some(owners),
38991 content_hash: 0,
38992 };
38993
38994 assert!(rec("tests/a.rs", vec![]).is_test_file());
38996 assert!(rec("src/foo.spec.ts", vec![]).is_test_file());
38997 assert!(!rec("src/b.rs", vec![]).is_test_file());
38998 let mut lexical = rec("src/c.rs", vec![]);
39000 lexical.raw_line_categories.test_count = 1;
39001 assert!(lexical.is_test_file());
39002
39003 let json = serde_json::json!({
39004 "tool": {"name":"oxide-sloc","version":"0.0.0","run_id":"t","timestamp_utc":"2026-01-01T00:00:00Z"},
39005 "environment": {"operating_system":"x","architecture":"x86_64","runtime_mode":"cli","initiator_username":"u","initiator_hostname":"h"},
39006 "effective_configuration": {},
39007 "input_roots": ["/tmp/myrepo"],
39008 "summary_totals": {"files_considered":2,"files_analyzed":2,"files_skipped":0,"total_physical_lines":130,"code_lines":130,"comment_lines":0,"blank_lines":0,"mixed_lines_separate":0},
39009 "totals_by_language": [],
39010 "per_file_records": [],
39011 "skipped_file_records": [],
39012 "warnings": [],
39013 "authors": [
39014 {"id":0,"canonical_name":"Nima Shafie","canonical_email":"nima@corp.com","aliases":[],
39015 "counts":{"code_lines":130,"comment_lines":0,"blank_lines":0,"total_lines":130}}
39016 ]
39017 });
39018 let mut run: AnalysisRun = serde_json::from_value(json).expect("run deserializes");
39019 run.per_file_records = vec![
39020 rec("src/b.rs", vec![own(0, 100)]),
39021 rec("tests/a.rs", vec![own(0, 30)]),
39022 ];
39023 let rows = build_ownership_rows(Some(&run), 130);
39024 assert_eq!(rows.len(), 1);
39025 assert_eq!(rows[0].code, 130);
39026 assert_eq!(rows[0].test_code, 30);
39028 assert_eq!(rows[0].code.saturating_sub(rows[0].test_code), 100);
39029
39030 let dj = ownership_data_json(&rows);
39032 assert!(dj.contains("\"test_code\":30"));
39033 }
39034
39035 #[test]
39036 fn server_mode_with_key_is_allowed() {
39037 assert!(!refuse_unauthenticated_server(true, true));
39038 }
39039
39040 #[test]
39043 fn server_mode_auth_gate_respects_optin() {
39044 unsafe { std::env::remove_var("SLOC_ALLOW_UNAUTHENTICATED") };
39046 assert!(
39047 refuse_unauthenticated_server(true, false),
39048 "server mode + no key must fail closed by default"
39049 );
39050 unsafe { std::env::set_var("SLOC_ALLOW_UNAUTHENTICATED", "1") };
39052 assert!(
39053 !refuse_unauthenticated_server(true, false),
39054 "explicit opt-in must allow the unauthenticated server"
39055 );
39056 unsafe { std::env::remove_var("SLOC_ALLOW_UNAUTHENTICATED") };
39058 }
39059
39060 #[test]
39063 fn dir_writable_true_for_temp_dir() {
39064 assert!(dir_writable(&std::env::temp_dir()));
39065 }
39066
39067 #[test]
39068 fn dir_writable_empty_path_is_ok() {
39069 assert!(dir_writable(std::path::Path::new("")));
39070 }
39071
39072 #[test]
39073 fn is_compressible_type_matches_text_and_json() {
39074 assert!(is_compressible_type("text/html; charset=utf-8"));
39075 assert!(is_compressible_type("application/json"));
39076 assert!(is_compressible_type("image/svg+xml"));
39077 assert!(is_compressible_type("application/javascript"));
39078 assert!(!is_compressible_type("application/pdf"));
39079 assert!(!is_compressible_type("application/gzip"));
39080 assert!(!is_compressible_type("image/png"));
39081 assert!(!is_compressible_type(""));
39082 }
39083
39084 #[test]
39085 fn client_accepts_gzip_parses_header() {
39086 let mut h = axum::http::HeaderMap::new();
39087 assert!(!client_accepts_gzip(&h));
39088 h.insert(
39089 header::ACCEPT_ENCODING,
39090 HeaderValue::from_static("br, gzip, deflate"),
39091 );
39092 assert!(client_accepts_gzip(&h));
39093 h.insert(
39094 header::ACCEPT_ENCODING,
39095 HeaderValue::from_static("identity"),
39096 );
39097 assert!(!client_accepts_gzip(&h));
39098 }
39099
39100 #[test]
39101 fn http_timeout_defaults_are_sane() {
39102 assert!(http_timeout() >= std::time::Duration::from_secs(1));
39104 }
39105
39106 #[test]
39107 fn uptime_seconds_is_monotonic_nonpanicking() {
39108 let _ = uptime_seconds();
39110 }
39111
39112 fn raw_tar_block(name: &str, data: &[u8]) -> Vec<u8> {
39118 let mut h = [0u8; 512];
39119 let nb = name.as_bytes();
39120 h[..nb.len()].copy_from_slice(nb);
39121 h[100..108].copy_from_slice(b"0000644\0");
39122 h[108..116].copy_from_slice(b"0000000\0");
39123 h[116..124].copy_from_slice(b"0000000\0");
39124 h[124..136].copy_from_slice(format!("{:011o}\0", data.len()).as_bytes());
39125 h[136..148].copy_from_slice(b"00000000000\0");
39126 h[156] = b'0'; h[257..263].copy_from_slice(b"ustar\0");
39128 h[263..265].copy_from_slice(b"00");
39129 for b in &mut h[148..156] {
39130 *b = b' ';
39131 }
39132 let sum: u32 = h.iter().map(|&b| u32::from(b)).sum();
39133 h[148..156].copy_from_slice(format!("{sum:06o}\0 ").as_bytes());
39134
39135 let mut out = h.to_vec();
39136 out.extend_from_slice(data);
39137 out.resize(out.len() + (512 - data.len() % 512) % 512, 0); out.resize(out.len() + 1024, 0); out
39140 }
39141
39142 #[tokio::test]
39146 async fn tarball_extraction_blocks_zip_slip() {
39147 use std::io::Write as _;
39148
39149 let base = std::env::temp_dir().join(format!("sloc_zipslip_{}", uuid::Uuid::new_v4()));
39150 let staging = base.join("staging");
39151 let tar_gz = base.join("evil.tar.gz");
39152 std::fs::create_dir_all(&base).unwrap();
39153
39154 {
39156 let f = std::fs::File::create(&tar_gz).unwrap();
39157 let mut enc = flate2::write::GzEncoder::new(f, flate2::Compression::default());
39158 enc.write_all(&raw_tar_block("../escaped.txt", b"pwned"))
39159 .unwrap();
39160 enc.finish().unwrap().flush().unwrap();
39161 }
39162
39163 let _ = extract_tarball_to_staging(&tar_gz, &staging, 10 * 1024 * 1024).await;
39165
39166 let escaped = base.join("escaped.txt");
39167 assert!(
39168 !escaped.exists(),
39169 "zip-slip entry escaped staging to {}",
39170 escaped.display()
39171 );
39172
39173 let _ = std::fs::remove_dir_all(&base);
39174 }
39175
39176 #[test]
39177 fn size_limit_reader_zero_remaining_returns_error() {
39178 let data = b"hello world";
39179 let mut reader = SizeLimitReader {
39180 inner: &data[..],
39181 remaining: 0,
39182 };
39183 let mut buf = [0u8; 4];
39184 assert!(reader.read(&mut buf).is_err());
39185 }
39186
39187 #[test]
39188 fn size_limit_reader_counts_bytes() {
39189 let data = b"hello world";
39190 let mut reader = SizeLimitReader {
39191 inner: &data[..],
39192 remaining: 5,
39193 };
39194 let mut buf = [0u8; 4];
39195 let n = reader.read(&mut buf).unwrap();
39196 assert_eq!(n, 4);
39197 assert_eq!(reader.remaining, 1);
39198 }
39199
39200 #[test]
39201 fn resolve_or_create_staging_with_valid_uuid_reuses_id() {
39202 let uuid = "12345678-1234-1234-1234-123456789012";
39203 let (id, path) = resolve_or_create_staging(Some(uuid));
39204 assert_eq!(id, uuid);
39205 assert!(path.to_string_lossy().contains("oxide-sloc-uploads"));
39206 }
39207
39208 #[test]
39209 fn resolve_or_create_staging_with_none_creates_new() {
39210 let (id1, _) = resolve_or_create_staging(None);
39211 let (id2, _) = resolve_or_create_staging(None);
39212 assert_ne!(id1, id2);
39213 }
39214
39215 #[test]
39216 fn resolve_or_create_staging_with_path_separator_creates_new() {
39217 let (id, _) = resolve_or_create_staging(Some("has/slash"));
39219 assert_ne!(id, "has/slash");
39220 }
39221
39222 #[test]
39223 fn auth_lockout_remaining_secs_no_entry_returns_zero() {
39224 use std::net::IpAddr;
39225 use std::str::FromStr;
39226 let limiter = IpRateLimiter::new(Duration::from_mins(1), 100, 5, Duration::from_mins(5));
39227 let ip = IpAddr::from_str("192.168.1.1").unwrap();
39228 assert_eq!(limiter.auth_lockout_remaining_secs(ip), 0);
39229 }
39230
39231 #[test]
39232 fn is_auth_locked_out_expired_entry_removed() {
39233 use std::net::IpAddr;
39234 use std::str::FromStr;
39235 let limiter = IpRateLimiter::new(
39236 Duration::from_mins(1),
39237 100,
39238 1, Duration::from_millis(1),
39240 );
39241 let ip = IpAddr::from_str("192.168.1.2").unwrap();
39242 limiter.record_auth_failure(ip);
39243 std::thread::sleep(Duration::from_millis(10));
39245 assert!(!limiter.is_auth_locked_out(ip));
39247 }
39248
39249 #[test]
39250 fn is_auth_locked_out_within_window_returns_true() {
39251 use std::net::IpAddr;
39252 use std::str::FromStr;
39253 let limiter = IpRateLimiter::new(
39254 Duration::from_mins(1),
39255 100,
39256 2, Duration::from_hours(1),
39258 );
39259 let ip = IpAddr::from_str("192.168.1.3").unwrap();
39260 limiter.record_auth_failure(ip);
39261 limiter.record_auth_failure(ip);
39262 assert!(limiter.is_auth_locked_out(ip));
39263 }
39264
39265 #[test]
39268 fn output_folder_hint_strips_json_subdir() {
39269 use std::path::Path;
39270 let path = Path::new("/output/scan1/json/result.json");
39271 let hint = output_folder_hint(path);
39272 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
39273 }
39274
39275 #[test]
39276 fn output_folder_hint_strips_html_subdir() {
39277 use std::path::Path;
39278 let path = Path::new("/output/scan1/html/report.html");
39279 let hint = output_folder_hint(path);
39280 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
39281 }
39282
39283 #[test]
39284 fn output_folder_hint_strips_pdf_subdir() {
39285 use std::path::Path;
39286 let path = Path::new("/output/scan1/pdf/report.pdf");
39287 let hint = output_folder_hint(path);
39288 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
39289 }
39290
39291 #[test]
39292 fn output_folder_hint_strips_excel_subdir() {
39293 use std::path::Path;
39294 let path = Path::new("/output/scan1/excel/report.xlsx");
39295 let hint = output_folder_hint(path);
39296 assert!(hint.ends_with("scan1"), "expected scan root, got: {hint}");
39297 }
39298
39299 #[test]
39300 fn output_folder_hint_flat_layout_returns_direct_parent() {
39301 use std::path::Path;
39302 let path = Path::new("/output/scan1/result.json");
39303 let hint = output_folder_hint(path);
39304 assert!(
39305 hint.ends_with("scan1"),
39306 "expected direct parent, got: {hint}"
39307 );
39308 }
39309
39310 #[test]
39311 fn output_folder_hint_other_subdir_name_not_stripped() {
39312 use std::path::Path;
39313 let path = Path::new("/output/scan1/data/result.json");
39315 let hint = output_folder_hint(path);
39316 assert!(
39317 hint.ends_with("data"),
39318 "non-artifact subdir must not be stripped, got: {hint}"
39319 );
39320 }
39321
39322 #[test]
39325 fn find_file_by_ext_finds_matching_file() {
39326 let dir = std::env::temp_dir().join("sloc_web_fbe_test");
39327 let _ = fs::create_dir_all(&dir);
39328 let f = dir.join("report.pdf");
39329 let _ = fs::write(&f, b"dummy");
39330 let result = find_file_by_ext(&dir, "pdf");
39331 assert!(result.is_some(), "expected to find report.pdf");
39332 let _ = fs::remove_dir_all(&dir);
39333 }
39334
39335 #[test]
39336 fn find_file_by_ext_returns_none_for_missing_ext() {
39337 let dir = std::env::temp_dir().join("sloc_web_fbe_test2");
39338 let _ = fs::create_dir_all(&dir);
39339 let f = dir.join("report.json");
39340 let _ = fs::write(&f, b"{}");
39341 let result = find_file_by_ext(&dir, "pdf");
39342 assert!(result.is_none());
39343 let _ = fs::remove_dir_all(&dir);
39344 }
39345
39346 #[test]
39347 fn find_file_by_ext_returns_none_for_nonexistent_dir() {
39348 let dir = std::path::Path::new("/nonexistent/dir/that/does/not/exist");
39349 assert!(find_file_by_ext(dir, "json").is_none());
39350 }
39351
39352 #[test]
39355 fn collect_result_json_candidates_flat_root() {
39356 let root = std::env::temp_dir().join("sloc_web_crjc_flat");
39357 let _ = fs::create_dir_all(&root);
39358 let _ = fs::write(root.join("result.json"), b"{}");
39359 let candidates = collect_result_json_candidates(&root);
39360 assert!(!candidates.is_empty(), "should find result.json at root");
39361 let _ = fs::remove_dir_all(&root);
39362 }
39363
39364 #[test]
39365 fn collect_result_json_candidates_legacy_subdir() {
39366 let root = std::env::temp_dir().join("sloc_web_crjc_legacy");
39367 let sub = root.join("scanA");
39368 let _ = fs::create_dir_all(&sub);
39369 let _ = fs::write(sub.join("result.json"), b"{}");
39370 let candidates = collect_result_json_candidates(&root);
39371 assert!(
39372 !candidates.is_empty(),
39373 "should find result.json in legacy subdir"
39374 );
39375 let _ = fs::remove_dir_all(&root);
39376 }
39377
39378 #[test]
39379 fn collect_result_json_candidates_structured_json_subdir() {
39380 let root = std::env::temp_dir().join("sloc_web_crjc_struct");
39381 let json_sub = root.join("scanB").join("json");
39382 let _ = fs::create_dir_all(&json_sub);
39383 let _ = fs::write(json_sub.join("result.json"), b"{}");
39384 let candidates = collect_result_json_candidates(&root);
39385 assert!(
39386 !candidates.is_empty(),
39387 "should find result.json inside <subdir>/json/"
39388 );
39389 let _ = fs::remove_dir_all(&root);
39390 }
39391
39392 #[test]
39393 fn collect_result_json_candidates_empty_dir() {
39394 let root = std::env::temp_dir().join("sloc_web_crjc_empty");
39395 let _ = fs::create_dir_all(&root);
39396 let candidates = collect_result_json_candidates(&root);
39397 assert!(candidates.is_empty());
39398 let _ = fs::remove_dir_all(&root);
39399 }
39400}